1403 lines
70 KiB
Python
1403 lines
70 KiB
Python
from concurrent.futures import Future
|
|
import ctypes
|
|
from datetime import datetime, timezone
|
|
import os
|
|
from pathlib import Path
|
|
import signal
|
|
import subprocess
|
|
from types import SimpleNamespace
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
APP_DIR = ROOT / 'app'
|
|
sys.path.insert(0, str(APP_DIR))
|
|
|
|
import postgres_runtime
|
|
import process_identity
|
|
from paths import apply_path_config
|
|
from postgres_runtime import (
|
|
ClusterIdentityError,
|
|
OnlineUnavailable,
|
|
PostgresBackend,
|
|
PostgresController,
|
|
PostgresState,
|
|
ProbeKind,
|
|
ProbeResult,
|
|
StartResult,
|
|
StopResult,
|
|
)
|
|
from runtime_security import ensure_private_directory
|
|
|
|
|
|
class PostgresRuntimePathTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.enterContext(mock.patch.object(
|
|
postgres_runtime, 'require_trusted_native_executable', side_effect=lambda path: path,
|
|
))
|
|
|
|
@staticmethod
|
|
def identity(paths, data_directory):
|
|
executable_keys = ('postgres', 'pg_ctl', 'pg_isready', 'pg_controldata')
|
|
return {
|
|
'schema': postgres_runtime.CLUSTER_IDENTITY_SCHEMA,
|
|
'private_file_ready': True,
|
|
'data_directory': data_directory,
|
|
'pg_major': 16,
|
|
'executables': {key: paths[key] for key in executable_keys},
|
|
'executable_sha256': {key: 'fixture-hash' for key in executable_keys},
|
|
'database': 'truf',
|
|
'user': 'truf',
|
|
'port': 5432,
|
|
'system_identifier': '12345',
|
|
'created_at': '2026-01-01T00:00:00+00:00',
|
|
}
|
|
|
|
def test_default_data_directory_is_unchanged(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
runtime_dir = os.path.join(temp_dir, 'runtime')
|
|
paths = postgres_runtime.postgres_runtime_paths({
|
|
'global': {'runtime_dir': runtime_dir},
|
|
})
|
|
self.assertEqual(
|
|
paths['data_dir'],
|
|
postgres_runtime.canonical_path(os.path.join(runtime_dir, 'postgres', 'data')),
|
|
)
|
|
|
|
def test_external_data_directory_expands_without_moving_runtime_assets(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
config = apply_path_config({
|
|
'global': {
|
|
'root_dir': temp_dir,
|
|
'project_dir': os.path.join(temp_dir, 'app'),
|
|
'runtime_dir': '{root_dir}/runtime',
|
|
'postgres_data_dir': '{root_dir}/external/../external/data',
|
|
},
|
|
})
|
|
paths = postgres_runtime.postgres_runtime_paths(config)
|
|
postgres_dir = postgres_runtime.canonical_path(os.path.join(temp_dir, 'runtime', 'postgres'))
|
|
self.assertEqual(
|
|
paths['data_dir'],
|
|
postgres_runtime.canonical_path(os.path.join(temp_dir, 'external', 'data')),
|
|
)
|
|
self.assertEqual(paths['postgres_dir'], postgres_dir)
|
|
self.assertEqual(paths['log_dir'], postgres_runtime.canonical_path(os.path.join(postgres_dir, 'logs')))
|
|
self.assertEqual(
|
|
postgres_runtime.canonical_path(paths['identity_path']),
|
|
postgres_runtime.canonical_path(os.path.join(postgres_dir, 'cluster_identity.json')),
|
|
)
|
|
|
|
def test_external_data_identity_mismatch_remains_fail_closed(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
runtime_dir = os.path.join(temp_dir, 'runtime')
|
|
external_data = os.path.join(temp_dir, 'external-data')
|
|
config = {'global': {
|
|
'runtime_dir': runtime_dir,
|
|
'postgres_data_dir': external_data,
|
|
}}
|
|
paths = postgres_runtime.postgres_runtime_paths(config)
|
|
stale_identity = self.identity(
|
|
paths,
|
|
postgres_runtime.canonical_path(os.path.join(runtime_dir, 'postgres', 'data')),
|
|
)
|
|
with mock.patch.object(postgres_runtime, 'read_private_json', return_value=stale_identity), \
|
|
mock.patch.object(postgres_runtime, 'configured_cluster_values', return_value={
|
|
'database': 'truf', 'user': 'truf', 'port': 5432,
|
|
}):
|
|
with self.assertRaisesRegex(ClusterIdentityError, 'data_directory mismatch'):
|
|
postgres_runtime.verify_cluster_identity(config)
|
|
|
|
def test_external_data_identity_verifies_only_when_exactly_bound(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
config = {'global': {
|
|
'runtime_dir': os.path.join(temp_dir, 'runtime'),
|
|
'postgres_data_dir': os.path.join(temp_dir, 'external-data'),
|
|
}}
|
|
paths = postgres_runtime.postgres_runtime_paths(config)
|
|
identity = self.identity(paths, paths['data_dir'])
|
|
with mock.patch.object(postgres_runtime, 'read_private_json', return_value=identity), \
|
|
mock.patch.object(postgres_runtime, 'configured_cluster_values', return_value={
|
|
'database': 'truf', 'user': 'truf', 'port': 5432,
|
|
}), mock.patch.object(postgres_runtime.os.path, 'isfile', return_value=True), \
|
|
mock.patch.object(postgres_runtime, 'sha256_file', return_value='fixture-hash'), \
|
|
mock.patch.object(postgres_runtime, '_data_directory_major', return_value=16), \
|
|
mock.patch.object(postgres_runtime, '_system_identifier', return_value='12345'):
|
|
verified = postgres_runtime.verify_cluster_identity(config)
|
|
self.assertEqual(verified['data_directory'], paths['data_dir'])
|
|
|
|
def test_bootstrap_major_executes_and_matches_bundled_postgres(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
data_dir = os.path.join(temp_dir, 'data')
|
|
os.makedirs(data_dir)
|
|
Path(os.path.join(data_dir, 'PG_VERSION')).write_text('16\n', encoding='ascii')
|
|
paths = {'data_dir': data_dir, 'postgres': os.path.join(temp_dir, 'postgres.exe')}
|
|
completed = SimpleNamespace(returncode=0, stdout='postgres (PostgreSQL) 16.4')
|
|
with mock.patch.object(postgres_runtime, '_run_bounded', return_value=completed) as run:
|
|
self.assertEqual(postgres_runtime._bootstrap_postgres_major(paths), 16)
|
|
run.assert_called_once_with([paths['postgres'], '--version'], timeout=10)
|
|
|
|
completed.stdout = 'postgres (PostgreSQL) 17.0'
|
|
with mock.patch.object(postgres_runtime, '_run_bounded', return_value=completed), \
|
|
self.assertRaisesRegex(ClusterIdentityError, 'does not match'):
|
|
postgres_runtime._bootstrap_postgres_major(paths)
|
|
|
|
def test_bootstrap_identity_uses_executable_version_validation(self):
|
|
paths = {
|
|
'data_dir': 'data', 'identity_path': 'identity',
|
|
'postgres': 'postgres', 'pg_ctl': 'pg_ctl',
|
|
'pg_isready': 'pg_isready', 'pg_controldata': 'pg_controldata',
|
|
}
|
|
with mock.patch.object(postgres_runtime, 'postgres_runtime_paths', return_value=paths), \
|
|
mock.patch.object(postgres_runtime.os.path, 'isdir', return_value=True), \
|
|
mock.patch.object(postgres_runtime.os.path, 'isfile', return_value=True), \
|
|
mock.patch.object(postgres_runtime, '_require_bootstrap_offline'), \
|
|
mock.patch.object(postgres_runtime, '_bootstrap_postgres_major', return_value=16) as major, \
|
|
mock.patch.object(postgres_runtime, 'sha256_file', return_value='fixture-hash'), \
|
|
mock.patch.object(postgres_runtime, '_system_identifier', return_value='12345'), \
|
|
mock.patch.object(postgres_runtime, 'write_private_json_exclusive'), \
|
|
mock.patch.object(postgres_runtime, 'verify_cluster_identity', return_value={'verified': True}):
|
|
self.assertEqual(postgres_runtime.bootstrap_cluster_identity({}), {'verified': True})
|
|
major.assert_called_once_with(paths)
|
|
|
|
def test_periodic_identity_verification_never_probes_postgres_version(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
config = {'global': {'runtime_dir': os.path.join(temp_dir, 'runtime')}}
|
|
paths = postgres_runtime.postgres_runtime_paths(config)
|
|
identity = self.identity(paths, paths['data_dir'])
|
|
with mock.patch.object(postgres_runtime, 'read_private_json', return_value=identity), \
|
|
mock.patch.object(postgres_runtime, 'configured_cluster_values', return_value={
|
|
'database': 'truf', 'user': 'truf', 'port': 5432,
|
|
}), mock.patch.object(postgres_runtime.os.path, 'isfile', return_value=True), \
|
|
mock.patch.object(postgres_runtime, 'sha256_file', return_value='fixture-hash'), \
|
|
mock.patch.object(postgres_runtime, '_data_directory_major', return_value=16), \
|
|
mock.patch.object(postgres_runtime, '_system_identifier', return_value='12345'), \
|
|
mock.patch.object(postgres_runtime, '_bundled_postgres_major') as executable_major:
|
|
postgres_runtime.verify_cluster_identity(config)
|
|
executable_major.assert_not_called()
|
|
|
|
def test_periodic_identity_verification_still_rejects_executable_hash_drift(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
config = {'global': {'runtime_dir': os.path.join(temp_dir, 'runtime')}}
|
|
paths = postgres_runtime.postgres_runtime_paths(config)
|
|
identity = self.identity(paths, paths['data_dir'])
|
|
with mock.patch.object(postgres_runtime, 'read_private_json', return_value=identity), \
|
|
mock.patch.object(postgres_runtime, 'configured_cluster_values', return_value={
|
|
'database': 'truf', 'user': 'truf', 'port': 5432,
|
|
}), mock.patch.object(postgres_runtime.os.path, 'isfile', return_value=True), \
|
|
mock.patch.object(postgres_runtime, 'sha256_file', return_value='drifted-hash'):
|
|
with self.assertRaisesRegex(ClusterIdentityError, 'executable hash mismatch'):
|
|
postgres_runtime.verify_cluster_identity(config)
|
|
|
|
def test_periodic_identity_verification_still_rejects_pg_version_drift(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
config = {'global': {'runtime_dir': os.path.join(temp_dir, 'runtime')}}
|
|
paths = postgres_runtime.postgres_runtime_paths(config)
|
|
identity = self.identity(paths, paths['data_dir'])
|
|
with mock.patch.object(postgres_runtime, 'read_private_json', return_value=identity), \
|
|
mock.patch.object(postgres_runtime, 'configured_cluster_values', return_value={
|
|
'database': 'truf', 'user': 'truf', 'port': 5432,
|
|
}), mock.patch.object(postgres_runtime.os.path, 'isfile', return_value=True), \
|
|
mock.patch.object(postgres_runtime, 'sha256_file', return_value='fixture-hash'), \
|
|
mock.patch.object(postgres_runtime, '_data_directory_major', return_value=17):
|
|
with self.assertRaisesRegex(ClusterIdentityError, 'major version changed'):
|
|
postgres_runtime.verify_cluster_identity(config)
|
|
|
|
def test_periodic_identity_verification_still_rejects_system_identifier_drift(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
config = {'global': {'runtime_dir': os.path.join(temp_dir, 'runtime')}}
|
|
paths = postgres_runtime.postgres_runtime_paths(config)
|
|
identity = self.identity(paths, paths['data_dir'])
|
|
with mock.patch.object(postgres_runtime, 'read_private_json', return_value=identity), \
|
|
mock.patch.object(postgres_runtime, 'configured_cluster_values', return_value={
|
|
'database': 'truf', 'user': 'truf', 'port': 5432,
|
|
}), mock.patch.object(postgres_runtime.os.path, 'isfile', return_value=True), \
|
|
mock.patch.object(postgres_runtime, 'sha256_file', return_value='fixture-hash'), \
|
|
mock.patch.object(postgres_runtime, '_data_directory_major', return_value=16), \
|
|
mock.patch.object(postgres_runtime, '_system_identifier', return_value='99999') as system_id:
|
|
with self.assertRaisesRegex(ClusterIdentityError, 'system identifier changed'):
|
|
postgres_runtime.verify_cluster_identity(config)
|
|
system_id.assert_called_once_with(paths)
|
|
|
|
def test_live_probe_uses_online_system_identifier_without_pg_controldata(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.config = {}
|
|
backend._verified_identity = None
|
|
identity = {'port': 5432, 'data_directory': 'data'}
|
|
with mock.patch.object(backend, '_root_job_error', return_value=None), \
|
|
mock.patch.object(backend, '_prune_logging'), \
|
|
mock.patch.object(backend, '_online_query', return_value=(False, 'epoch')), \
|
|
mock.patch.object(postgres_runtime, 'verify_cluster_identity', return_value=identity) as verify:
|
|
result = backend.probe()
|
|
self.assertEqual(result.kind, ProbeKind.READY)
|
|
verify.assert_called_once_with({}, verify_offline_system_identifier=False)
|
|
|
|
def test_offline_probe_verifies_system_identifier_before_reporting_stopped(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.config = {}
|
|
backend._verified_identity = None
|
|
backend._expected_process = None
|
|
backend._start_requested_wall_time = None
|
|
backend._accepted_start_at_monotonic = None
|
|
identity = {'port': 5432, 'data_directory': 'data'}
|
|
with mock.patch.object(backend, '_root_job_error', return_value=None), \
|
|
mock.patch.object(backend, '_prune_logging'), \
|
|
mock.patch.object(backend, '_online_query', side_effect=OnlineUnavailable('offline')), \
|
|
mock.patch.object(backend, '_expected_is_live', return_value=False), \
|
|
mock.patch.object(backend, '_hint_running', return_value=False), \
|
|
mock.patch.object(postgres_runtime, '_parse_postmaster_pid', return_value=None), \
|
|
mock.patch.object(postgres_runtime, '_listener_present', return_value=False), \
|
|
mock.patch.object(postgres_runtime, 'verify_cluster_identity', return_value=identity) as verify:
|
|
result = backend.probe()
|
|
self.assertEqual(result.kind, ProbeKind.STOPPED)
|
|
self.assertEqual(verify.call_args_list, [
|
|
mock.call({}, verify_offline_system_identifier=False),
|
|
mock.call({}),
|
|
])
|
|
|
|
def test_offline_probe_accepts_stale_pid_for_definitively_absent_process(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.config = {}
|
|
backend._verified_identity = None
|
|
backend._expected_process = None
|
|
backend._start_requested_wall_time = None
|
|
backend._accepted_start_at_monotonic = None
|
|
identity = {'port': 5432, 'data_directory': 'data'}
|
|
with mock.patch.object(backend, '_root_job_error', return_value=None), \
|
|
mock.patch.object(backend, '_prune_logging'), \
|
|
mock.patch.object(backend, '_online_query', side_effect=OnlineUnavailable('offline')), \
|
|
mock.patch.object(backend, '_expected_is_live', return_value=False), \
|
|
mock.patch.object(backend, '_open_postmaster', side_effect=postgres_runtime.PostmasterProcessAbsent('absent')), \
|
|
mock.patch.object(backend, '_hint_running', return_value=False), \
|
|
mock.patch.object(postgres_runtime, '_parse_postmaster_pid', return_value=20892), \
|
|
mock.patch.object(postgres_runtime, '_listener_present', return_value=False), \
|
|
mock.patch.object(postgres_runtime, 'verify_cluster_identity', return_value=identity) as verify:
|
|
result = backend.probe()
|
|
self.assertEqual(result.kind, ProbeKind.STOPPED)
|
|
self.assertEqual(verify.call_args_list, [
|
|
mock.call({}, verify_offline_system_identifier=False),
|
|
mock.call({}),
|
|
])
|
|
|
|
@unittest.skipUnless(os.name == 'nt', 'Windows retained process semantics')
|
|
def test_open_process_rejects_definitively_exited_retained_handle(self):
|
|
def exited(_handle, code_pointer):
|
|
code_pointer._obj.value = 0
|
|
return True
|
|
|
|
with mock.patch.object(process_identity, '_OPEN_PROCESS', return_value=1234), \
|
|
mock.patch.object(process_identity, '_WAIT_FOR_SINGLE_OBJECT', return_value=0), \
|
|
mock.patch.object(process_identity, '_GET_EXIT_CODE_PROCESS', side_effect=exited), \
|
|
mock.patch.object(process_identity, '_CLOSE_HANDLE') as close_handle, \
|
|
mock.patch.object(process_identity, '_windows_identity') as inspect_identity:
|
|
with self.assertRaisesRegex(process_identity.ProcessExitedError, 'already exited'):
|
|
process_identity.open_process(20892)
|
|
inspect_identity.assert_not_called()
|
|
close_handle.assert_called_once_with(1234)
|
|
|
|
@unittest.skipUnless(os.name == 'nt', 'Windows retained process semantics')
|
|
def test_live_uninspectable_process_remains_fail_closed(self):
|
|
def still_active(_handle, code_pointer):
|
|
code_pointer._obj.value = 259
|
|
return True
|
|
|
|
native_error = ctypes.WinError(31)
|
|
with mock.patch.object(process_identity, '_OPEN_PROCESS', return_value=1234), \
|
|
mock.patch.object(process_identity, '_WAIT_FOR_SINGLE_OBJECT', return_value=258), \
|
|
mock.patch.object(process_identity, '_GET_EXIT_CODE_PROCESS', side_effect=still_active), \
|
|
mock.patch.object(process_identity, '_CLOSE_HANDLE') as close_handle, \
|
|
mock.patch.object(process_identity, '_windows_identity', side_effect=native_error):
|
|
with self.assertRaises(process_identity.ProcessIdentityError) as caught:
|
|
process_identity.open_process(20892)
|
|
self.assertNotIsInstance(caught.exception, process_identity.ProcessExitedError)
|
|
self.assertIs(caught.exception.__cause__, native_error)
|
|
close_handle.assert_called_once_with(1234)
|
|
|
|
@unittest.skipUnless(os.name == 'nt', 'Windows retained process semantics')
|
|
def test_retained_windows_liveness_uses_wait_handle_not_still_active_code(self):
|
|
identity = process_identity.ProcessIdentity(
|
|
pid=20892, creation_time='windows-filetime:1',
|
|
creation_time_unix=1.0, executable=os.path.abspath(sys.executable),
|
|
in_job=False,
|
|
)
|
|
retained = process_identity.RetainedProcess(identity, handle=1234)
|
|
with mock.patch.object(
|
|
process_identity, '_WAIT_FOR_SINGLE_OBJECT', return_value=258,
|
|
), mock.patch.object(
|
|
process_identity, '_GET_EXIT_CODE_PROCESS',
|
|
side_effect=AssertionError('STILL_ACTIVE must not decide liveness'),
|
|
):
|
|
self.assertTrue(retained.is_running())
|
|
with mock.patch.object(
|
|
process_identity, '_WAIT_FOR_SINGLE_OBJECT', return_value=0,
|
|
):
|
|
self.assertFalse(retained.is_running())
|
|
retained._closed = True
|
|
|
|
def test_retained_posix_termination_prefers_exact_pidfd_signal(self):
|
|
identity = process_identity.ProcessIdentity(
|
|
pid=20892, creation_time='proc-start-ticks:1',
|
|
creation_time_unix=1.0, executable='/usr/bin/python3',
|
|
in_job=False,
|
|
)
|
|
retained = process_identity.RetainedProcess(identity, pidfd=77)
|
|
with mock.patch.object(process_identity.os, 'name', 'posix'), \
|
|
mock.patch.object(
|
|
process_identity.signal, 'pidfd_send_signal', create=True,
|
|
) as send, mock.patch.object(
|
|
process_identity.os, 'kill',
|
|
side_effect=AssertionError('numeric PID signal is forbidden with pidfd'),
|
|
):
|
|
retained.terminate()
|
|
send.assert_called_once_with(77, signal.SIGTERM, None, 0)
|
|
retained._closed = True
|
|
|
|
def test_posix_identity_binding_opens_pidfd_before_proc_identity(self):
|
|
events = []
|
|
identity = process_identity.ProcessIdentity(
|
|
pid=20892, creation_time='proc-start-ticks:1',
|
|
creation_time_unix=1.0, executable='/usr/bin/python3',
|
|
in_job=False,
|
|
)
|
|
|
|
def pin(_pid, _flags):
|
|
events.append('pidfd_open')
|
|
return 77
|
|
|
|
def inspect(_pid):
|
|
events.append('identity')
|
|
return identity
|
|
|
|
with mock.patch.object(process_identity.os, 'name', 'posix'), \
|
|
mock.patch.object(
|
|
process_identity.os, 'pidfd_open', side_effect=pin, create=True,
|
|
), mock.patch.object(
|
|
process_identity, '_pidfd_live', return_value=True,
|
|
), mock.patch.object(
|
|
process_identity, '_posix_identity', side_effect=inspect,
|
|
):
|
|
retained = process_identity.open_process(20892)
|
|
self.assertEqual(events, ['pidfd_open', 'identity', 'identity'])
|
|
retained._closed = True
|
|
|
|
def test_posix_pidfd_exit_during_identity_binding_fails_closed(self):
|
|
identity = process_identity.ProcessIdentity(
|
|
pid=20892, creation_time='proc-start-ticks:1',
|
|
creation_time_unix=1.0, executable='/usr/bin/python3',
|
|
in_job=False,
|
|
)
|
|
with mock.patch.object(process_identity.os, 'name', 'posix'), \
|
|
mock.patch.object(
|
|
process_identity.os, 'pidfd_open', return_value=77, create=True,
|
|
), mock.patch.object(
|
|
process_identity, '_pidfd_live', side_effect=(True, False),
|
|
), mock.patch.object(
|
|
process_identity, '_posix_identity', return_value=identity,
|
|
), mock.patch.object(process_identity.os, 'close') as close:
|
|
with self.assertRaises(process_identity.ProcessExitedError):
|
|
process_identity.open_process(20892)
|
|
close.assert_called_once_with(77)
|
|
|
|
def test_postmaster_treats_only_typed_exited_process_as_absent(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
data_dir = os.path.join(temp_dir, 'data')
|
|
os.makedirs(data_dir)
|
|
Path(os.path.join(data_dir, 'postmaster.pid')).write_text(
|
|
f'20892\n{data_dir}\n1700000000\n',
|
|
encoding='ascii',
|
|
)
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
identity = {
|
|
'data_directory': postgres_runtime.canonical_path(data_dir),
|
|
'executables': {'postgres': postgres_runtime.canonical_path('postgres.exe')},
|
|
}
|
|
with mock.patch.object(
|
|
postgres_runtime,
|
|
'open_process',
|
|
side_effect=process_identity.ProcessExitedError('exited'),
|
|
):
|
|
with self.assertRaisesRegex(postgres_runtime.PostmasterProcessAbsent, 'exited process'):
|
|
backend._open_postmaster(identity)
|
|
|
|
unknown = process_identity.ProcessIdentityError('uninspectable')
|
|
unknown.__cause__ = ctypes.WinError(31) if os.name == 'nt' else OSError(5, 'uninspectable')
|
|
with mock.patch.object(postgres_runtime, 'open_process', side_effect=unknown):
|
|
with self.assertRaises(ClusterIdentityError) as caught:
|
|
backend._open_postmaster(identity)
|
|
self.assertNotIsInstance(caught.exception, postgres_runtime.PostmasterProcessAbsent)
|
|
|
|
|
|
class ImmediateExecutor:
|
|
def submit(self, function):
|
|
future = Future()
|
|
try:
|
|
future.set_result(function())
|
|
except BaseException as exc:
|
|
future.set_exception(exc)
|
|
return future
|
|
|
|
|
|
class ControlledExecutor:
|
|
def __init__(self):
|
|
self.submissions = []
|
|
|
|
def submit(self, function):
|
|
future = Future()
|
|
self.submissions.append((function.__name__, future))
|
|
return future
|
|
|
|
|
|
class FakeBackend:
|
|
def __init__(self, probes, starts=None, stops=None):
|
|
self.probes = list(probes)
|
|
self.starts = list(starts or [StartResult(True, 'accepted')])
|
|
self.stops = list(stops or [StopResult(True, True, 'stopped')])
|
|
self.probe_calls = 0
|
|
self.start_calls = 0
|
|
self.stop_calls = 0
|
|
self.close_calls = 0
|
|
|
|
def probe(self):
|
|
self.probe_calls += 1
|
|
return self.probes.pop(0)
|
|
|
|
def start(self):
|
|
self.start_calls += 1
|
|
return self.starts.pop(0)
|
|
|
|
def stop(self):
|
|
self.stop_calls += 1
|
|
result = self.stops.pop(0)
|
|
if isinstance(result, BaseException):
|
|
raise result
|
|
return result
|
|
|
|
def close(self):
|
|
self.close_calls += 1
|
|
|
|
|
|
def immediate_controller(backend, **options):
|
|
return PostgresController(
|
|
backend,
|
|
executor=ImmediateExecutor(),
|
|
health_interval_sec=options.pop('health_interval_sec', 5),
|
|
stable_ready_interval_sec=options.pop('stable_ready_interval_sec', 10),
|
|
**options,
|
|
)
|
|
|
|
|
|
class PostgresControllerTests(unittest.TestCase):
|
|
def test_maintenance_start_waits_for_authenticated_ready(self):
|
|
backend = FakeBackend([
|
|
ProbeResult(ProbeKind.RECOVERING, 'starting'),
|
|
ProbeResult(ProbeKind.READY, 'authenticated'),
|
|
])
|
|
probe = postgres_runtime.maintenance_start({}, backend=backend)
|
|
self.assertEqual(probe.kind, ProbeKind.READY)
|
|
self.assertEqual(backend.start_calls, 1)
|
|
self.assertEqual(backend.stop_calls, 0)
|
|
self.assertEqual(backend.close_calls, 0)
|
|
|
|
def test_maintenance_stop_refuses_foreign_cluster(self):
|
|
backend = FakeBackend([], stops=[StopResult(False, False, 'foreign')])
|
|
with self.assertRaisesRegex(ClusterIdentityError, 'foreign'):
|
|
postgres_runtime.maintenance_stop({}, backend=backend)
|
|
self.assertEqual(backend.stop_calls, 1)
|
|
self.assertEqual(backend.close_calls, 0)
|
|
|
|
def test_failed_maintenance_start_leaves_supplied_backend_with_its_caller(self):
|
|
backend = FakeBackend([ProbeResult(ProbeKind.OWNED_START_UNCERTAIN, 'unconfirmed start')])
|
|
with self.assertRaisesRegex(ClusterIdentityError, 'unconfirmed start'):
|
|
postgres_runtime.maintenance_start({}, backend=backend)
|
|
self.assertEqual(backend.start_calls, 1)
|
|
self.assertEqual(backend.stop_calls, 0)
|
|
self.assertEqual(backend.close_calls, 0)
|
|
|
|
def test_bundled_utility_subprocess_uses_machine_readable_locale(self):
|
|
completed = SimpleNamespace(returncode=0, stdout='')
|
|
with mock.patch.object(postgres_runtime.subprocess, 'run', return_value=completed) as run, \
|
|
mock.patch.object(postgres_runtime, 'require_trusted_native_executable', side_effect=lambda path: path):
|
|
self.assertIs(postgres_runtime._run_bounded(['pg_controldata'], 1), completed)
|
|
self.assertEqual(run.call_args.kwargs['env']['LC_ALL'], 'C')
|
|
self.assertEqual(run.call_args.kwargs['env']['LANG'], 'C')
|
|
|
|
def test_start_accepted_then_early_death_enters_30_second_backoff(self):
|
|
backend = FakeBackend([
|
|
ProbeResult(ProbeKind.STOPPED, 'offline'),
|
|
ProbeResult(ProbeKind.STOPPED, 'early death'),
|
|
])
|
|
controller = immediate_controller(backend)
|
|
|
|
controller.tick(0)
|
|
controller.tick(0)
|
|
controller.tick(0)
|
|
self.assertEqual(controller.state, PostgresState.STARTING)
|
|
self.assertEqual(controller.failures, 0)
|
|
controller.tick(0)
|
|
|
|
self.assertEqual(controller.state, PostgresState.BACKOFF)
|
|
self.assertEqual(controller.failures, 1)
|
|
self.assertEqual(controller.next_action_at, 30)
|
|
self.assertEqual(backend.start_calls, 1)
|
|
|
|
def test_live_recovery_is_only_reprobed_and_never_restarted(self):
|
|
backend = FakeBackend([
|
|
ProbeResult(ProbeKind.RECOVERING, 'expected recovery'),
|
|
ProbeResult(ProbeKind.RECOVERING, 'still recovering'),
|
|
])
|
|
controller = immediate_controller(backend)
|
|
|
|
controller.tick(0)
|
|
controller.tick(0)
|
|
self.assertEqual(controller.state, PostgresState.RECOVERING)
|
|
controller.tick(5)
|
|
controller.tick(5)
|
|
|
|
self.assertEqual(controller.state, PostgresState.RECOVERING)
|
|
self.assertEqual(backend.start_calls, 0)
|
|
self.assertEqual(backend.stop_calls, 0)
|
|
|
|
def test_failure_streak_resets_only_after_stable_ready_interval(self):
|
|
backend = FakeBackend([
|
|
ProbeResult(ProbeKind.READY, 'ready once'),
|
|
ProbeResult(ProbeKind.READY, 'ready and stable'),
|
|
])
|
|
controller = immediate_controller(backend, stable_ready_interval_sec=10)
|
|
controller.failures = 3
|
|
|
|
controller.tick(0)
|
|
controller.tick(0)
|
|
self.assertEqual(controller.state, PostgresState.STABILIZING)
|
|
self.assertEqual(controller.failures, 3)
|
|
controller.tick(5)
|
|
controller.tick(10)
|
|
|
|
self.assertEqual(controller.state, PostgresState.READY)
|
|
self.assertEqual(controller.failures, 0)
|
|
|
|
def test_periodic_ready_probe_does_not_reenter_stabilizing(self):
|
|
backend = FakeBackend([
|
|
ProbeResult(ProbeKind.READY, 'initial'),
|
|
ProbeResult(ProbeKind.READY, 'stable'),
|
|
ProbeResult(ProbeKind.READY, 'periodic'),
|
|
])
|
|
controller = immediate_controller(backend, stable_ready_interval_sec=5)
|
|
controller.tick(0)
|
|
controller.tick(0)
|
|
controller.tick(5)
|
|
controller.tick(5)
|
|
self.assertEqual(controller.state, PostgresState.READY)
|
|
controller.tick(10)
|
|
controller.tick(10)
|
|
self.assertEqual(controller.state, PostgresState.READY)
|
|
|
|
def test_same_postmaster_transient_timeout_holds_ready_during_grace(self):
|
|
backend = FakeBackend([
|
|
ProbeResult(ProbeKind.READY, 'initial', 'epoch-a'),
|
|
ProbeResult(ProbeKind.RECOVERING, 'connection timeout', 'epoch-a'),
|
|
ProbeResult(ProbeKind.RECOVERING, 'connection timeout', 'epoch-a'),
|
|
])
|
|
controller = immediate_controller(
|
|
backend,
|
|
stable_ready_interval_sec=0,
|
|
health_interval_sec=15,
|
|
ready_loss_grace_sec=45,
|
|
)
|
|
controller.tick(0)
|
|
controller.tick(0)
|
|
self.assertEqual(controller.state, PostgresState.READY)
|
|
controller.tick(15)
|
|
controller.tick(15)
|
|
self.assertEqual(controller.state, PostgresState.READY)
|
|
self.assertIn('transient readiness loss', controller.detail)
|
|
controller.tick(30)
|
|
controller.tick(30)
|
|
self.assertEqual(controller.state, PostgresState.READY)
|
|
|
|
def test_same_postmaster_timeout_exceeding_grace_enters_recovery(self):
|
|
backend = FakeBackend([
|
|
ProbeResult(ProbeKind.READY, 'initial', 'epoch-a'),
|
|
ProbeResult(ProbeKind.RECOVERING, 'connection timeout', 'epoch-a'),
|
|
ProbeResult(ProbeKind.RECOVERING, 'connection timeout', 'epoch-a'),
|
|
])
|
|
controller = immediate_controller(
|
|
backend,
|
|
stable_ready_interval_sec=0,
|
|
health_interval_sec=45,
|
|
ready_loss_grace_sec=45,
|
|
)
|
|
controller.tick(0)
|
|
controller.tick(0)
|
|
controller.tick(45)
|
|
controller.tick(45)
|
|
self.assertEqual(controller.state, PostgresState.READY)
|
|
controller.tick(90)
|
|
controller.tick(90)
|
|
self.assertEqual(controller.state, PostgresState.RECOVERING)
|
|
|
|
def test_different_postmaster_recovery_bypasses_ready_loss_grace(self):
|
|
backend = FakeBackend([
|
|
ProbeResult(ProbeKind.READY, 'initial', 'epoch-a'),
|
|
ProbeResult(ProbeKind.RECOVERING, 'replacement unavailable', 'epoch-b'),
|
|
])
|
|
controller = immediate_controller(
|
|
backend,
|
|
stable_ready_interval_sec=0,
|
|
health_interval_sec=15,
|
|
ready_loss_grace_sec=45,
|
|
)
|
|
controller.tick(0)
|
|
controller.tick(0)
|
|
controller.tick(15)
|
|
controller.tick(15)
|
|
self.assertEqual(controller.state, PostgresState.RECOVERING)
|
|
|
|
def test_replaced_postmaster_must_stabilize_again(self):
|
|
backend = FakeBackend([
|
|
ProbeResult(ProbeKind.READY, 'first', 'epoch-a'),
|
|
ProbeResult(ProbeKind.READY, 'stable', 'epoch-a'),
|
|
ProbeResult(ProbeKind.READY, 'replacement', 'epoch-b'),
|
|
])
|
|
controller = immediate_controller(backend, stable_ready_interval_sec=5)
|
|
controller.tick(0)
|
|
controller.tick(0)
|
|
controller.tick(5)
|
|
controller.tick(5)
|
|
self.assertEqual(controller.state, PostgresState.READY)
|
|
controller.tick(10)
|
|
controller.tick(10)
|
|
self.assertEqual(controller.state, PostgresState.STABILIZING)
|
|
|
|
def test_foreign_identity_never_receives_lifecycle_action(self):
|
|
backend = FakeBackend([
|
|
ProbeResult(ProbeKind.FOREIGN_OR_CONFIG_ERROR, 'foreign listener'),
|
|
])
|
|
controller = immediate_controller(backend)
|
|
|
|
controller.tick(0)
|
|
controller.tick(0)
|
|
controller.tick(1000)
|
|
|
|
self.assertEqual(controller.state, PostgresState.FOREIGN_OR_CONFIG_ERROR)
|
|
self.assertEqual(backend.start_calls, 0)
|
|
self.assertEqual(backend.stop_calls, 0)
|
|
|
|
def test_late_probe_result_after_shutdown_is_ignored(self):
|
|
backend = FakeBackend([])
|
|
executor = ControlledExecutor()
|
|
controller = PostgresController(backend, executor=executor)
|
|
|
|
controller.tick(0)
|
|
self.assertEqual(executor.submissions[0][0], 'probe')
|
|
controller.request_stop()
|
|
executor.submissions[0][1].set_result(ProbeResult(ProbeKind.STOPPED, 'late'))
|
|
controller.tick(0)
|
|
|
|
self.assertEqual(controller.state, PostgresState.STOPPING)
|
|
self.assertEqual([name for name, _ in executor.submissions], ['probe', 'stop'])
|
|
self.assertEqual(backend.start_calls, 0)
|
|
executor.submissions[1][1].set_result(StopResult(True, True, 'stopped'))
|
|
controller.tick(0)
|
|
self.assertEqual(controller.state, PostgresState.STOPPED)
|
|
|
|
def test_shutdown_before_first_tick_performs_identity_safe_stop(self):
|
|
backend = FakeBackend([])
|
|
controller = immediate_controller(backend)
|
|
controller.request_stop()
|
|
controller.tick(0)
|
|
self.assertEqual(controller.state, PostgresState.STOPPING)
|
|
controller.tick(0)
|
|
self.assertEqual(controller.state, PostgresState.STOPPED)
|
|
self.assertEqual(backend.probe_calls, 0)
|
|
self.assertEqual(backend.start_calls, 0)
|
|
self.assertEqual(backend.stop_calls, 1)
|
|
|
|
def test_stop_refusal_and_exception_are_stop_failed(self):
|
|
refused = immediate_controller(
|
|
FakeBackend([], stops=[StopResult(False, False, 'recovery left running')]),
|
|
)
|
|
refused.request_stop()
|
|
refused.tick(0)
|
|
refused.tick(0)
|
|
self.assertEqual(refused.state, PostgresState.STOP_FAILED)
|
|
self.assertFalse(refused.stop_succeeded)
|
|
|
|
failed = immediate_controller(FakeBackend([], stops=[RuntimeError('worker failed')]))
|
|
failed.request_stop()
|
|
failed.tick(0)
|
|
failed.tick(0)
|
|
self.assertEqual(failed.state, PostgresState.STOP_FAILED)
|
|
self.assertIn('worker failed', failed.detail)
|
|
|
|
def test_foreign_state_is_revalidated_without_automatic_start(self):
|
|
backend = FakeBackend([
|
|
ProbeResult(ProbeKind.FOREIGN_OR_CONFIG_ERROR, 'foreign listener'),
|
|
ProbeResult(ProbeKind.STOPPED, 'listener disappeared'),
|
|
])
|
|
controller = immediate_controller(backend, health_interval_sec=5)
|
|
controller.tick(0)
|
|
controller.tick(0)
|
|
controller.tick(5)
|
|
controller.tick(5)
|
|
self.assertEqual(controller.state, PostgresState.FOREIGN_OR_CONFIG_ERROR)
|
|
self.assertEqual(backend.probe_calls, 2)
|
|
self.assertEqual(backend.start_calls, 0)
|
|
|
|
def test_foreign_start_result_latches_inert_across_later_stopped_probe(self):
|
|
backend = FakeBackend(
|
|
[
|
|
ProbeResult(ProbeKind.STOPPED, 'offline'),
|
|
ProbeResult(ProbeKind.STOPPED, 'still offline'),
|
|
],
|
|
starts=[StartResult(False, 'identity changed', foreign_or_config_error=True)],
|
|
)
|
|
controller = immediate_controller(backend, health_interval_sec=5)
|
|
controller.tick(0)
|
|
controller.tick(0)
|
|
controller.tick(0)
|
|
self.assertEqual(controller.state, PostgresState.FOREIGN_OR_CONFIG_ERROR)
|
|
self.assertTrue(controller.snapshot()['lifecycle_inert'])
|
|
controller.tick(5)
|
|
controller.tick(5)
|
|
self.assertEqual(controller.state, PostgresState.FOREIGN_OR_CONFIG_ERROR)
|
|
self.assertEqual(backend.start_calls, 1)
|
|
|
|
def test_recovery_adoption_remains_inert_until_online_identity(self):
|
|
backend = FakeBackend([
|
|
ProbeResult(ProbeKind.RECOVERING, 'adopted recovery'),
|
|
ProbeResult(ProbeKind.STOPPED, 'recovery disappeared'),
|
|
])
|
|
controller = immediate_controller(backend, health_interval_sec=5)
|
|
controller.tick(0)
|
|
controller.tick(0)
|
|
self.assertTrue(controller.snapshot()['lifecycle_inert'])
|
|
controller.tick(5)
|
|
controller.tick(5)
|
|
self.assertEqual(controller.state, PostgresState.FOREIGN_OR_CONFIG_ERROR)
|
|
self.assertEqual(backend.start_calls, 0)
|
|
|
|
def test_incomplete_stop_remains_nonterminal_until_result_arrives(self):
|
|
backend = FakeBackend([])
|
|
executor = ControlledExecutor()
|
|
controller = PostgresController(backend, executor=executor)
|
|
controller.request_stop()
|
|
controller.tick(0)
|
|
self.assertFalse(controller.terminal)
|
|
self.assertEqual(controller.state, PostgresState.STOPPING)
|
|
|
|
def test_late_accepted_start_after_authority_drift_schedules_compensating_stop(self):
|
|
backend = FakeBackend([])
|
|
executor = ControlledExecutor()
|
|
controller = PostgresController(backend, executor=executor)
|
|
controller.tick(0)
|
|
executor.submissions[0][1].set_result(ProbeResult(ProbeKind.STOPPED, 'offline'))
|
|
controller.tick(0)
|
|
self.assertEqual([name for name, _ in executor.submissions], ['probe', 'start'])
|
|
|
|
controller.inhibit_lifecycle('authority drift')
|
|
executor.submissions[1][1].set_result(StartResult(True, 'late accepted'))
|
|
controller.tick(0)
|
|
self.assertEqual([name for name, _ in executor.submissions], ['probe', 'start', 'stop'])
|
|
self.assertEqual(controller.state, PostgresState.STOPPING)
|
|
self.assertFalse(controller.stop_succeeded)
|
|
|
|
controller.request_stop()
|
|
executor.submissions[2][1].set_result(StopResult(True, True, 'compensated'))
|
|
controller.tick(0)
|
|
self.assertEqual(controller.state, PostgresState.STOPPED)
|
|
|
|
|
|
class FakeRetainedPostmaster:
|
|
def __init__(self, pid=321, creation_time='epoch'):
|
|
self.pid = pid
|
|
self.identity = SimpleNamespace(creation_time=creation_time, creation_time_unix=1700000000.0)
|
|
self.closed = False
|
|
|
|
def is_running(self):
|
|
return not self.closed
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
class StoppableRetainedPostmaster(FakeRetainedPostmaster):
|
|
def __init__(self, pid=321, creation_time='epoch'):
|
|
super().__init__(pid, creation_time)
|
|
self.running = True
|
|
|
|
def is_running(self):
|
|
return self.running and not self.closed
|
|
|
|
|
|
class PostgresBackendTests(unittest.TestCase):
|
|
def test_authenticated_handoff_can_stop_after_sql_readiness_is_lost(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.config = {}
|
|
backend.paths = {}
|
|
backend.connect_timeout_sec = 1
|
|
backend.query_timeout_ms = 1000
|
|
backend.stop_timeout_sec = 5
|
|
backend._expected_process = None
|
|
backend._start_requested_wall_time = None
|
|
backend._accepted_start_at_monotonic = None
|
|
backend._started_postmaster_observed = False
|
|
backend._authenticated_postmaster = None
|
|
process = StoppableRetainedPostmaster()
|
|
identity = {
|
|
'data_directory': postgres_runtime.canonical_path('fixture-data'),
|
|
'database': 'truf', 'user': 'truf', 'port': 55432, 'pg_major': 16,
|
|
'system_identifier': '123456789',
|
|
'executables': {'postgres': 'fixture-postgres', 'pg_ctl': 'fixture-pg_ctl'},
|
|
}
|
|
connection = mock.Mock()
|
|
connection.execute.return_value.fetchone.side_effect = [
|
|
{'database': 'truf', 'user_name': 'truf', 'port': 55432,
|
|
'data_directory': identity['data_directory'], 'in_recovery': False,
|
|
'postmaster_start_time': datetime.fromtimestamp(1700000000, timezone.utc)},
|
|
{'present': True, 'permitted': True}, {'system_identifier': '123456789'},
|
|
]
|
|
|
|
def stopped(*_args, **_kwargs):
|
|
process.running = False
|
|
return SimpleNamespace(returncode=0, stdout='')
|
|
|
|
with mock.patch.object(backend, '_root_job_error', return_value=None), \
|
|
mock.patch.object(postgres_runtime, 'verify_cluster_identity', return_value=identity), \
|
|
mock.patch.object(postgres_runtime, 'canonical_postgres_url', return_value='fixture-dsn'), \
|
|
mock.patch.object(postgres_runtime, 'database_url_from_env', return_value='fixture-dsn'), \
|
|
mock.patch.object(postgres_runtime, 'connect_postgres', side_effect=[
|
|
connection, RuntimeError('synthetic schema unavailable')]), \
|
|
mock.patch.object(backend, '_open_postmaster', return_value=process), \
|
|
mock.patch.object(postgres_runtime, '_parse_postmaster_pid', return_value=process.pid), \
|
|
mock.patch.object(postgres_runtime, '_run_bounded', side_effect=stopped) as run:
|
|
self.assertEqual(backend.probe().kind, ProbeKind.READY)
|
|
self.assertFalse(backend.owns_start, 'authentication is not launch ownership')
|
|
self.assertEqual(backend._authenticated_postmaster, (identity, process))
|
|
result = backend.stop()
|
|
self.assertTrue(result.completed and result.stopped)
|
|
self.assertTrue(process.closed)
|
|
self.assertIsNone(backend._authenticated_postmaster)
|
|
run.assert_called_once()
|
|
|
|
def test_missing_system_identity_authentication_does_not_mint_stop_proof(self):
|
|
for availability in ({'present': False, 'permitted': False},
|
|
{'present': True, 'permitted': False}):
|
|
with self.subTest(availability=availability):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.connect_timeout_sec = 1
|
|
backend.query_timeout_ms = 1000
|
|
backend._expected_process = None
|
|
backend._authenticated_postmaster = None
|
|
process = FakeRetainedPostmaster()
|
|
identity = {
|
|
'data_directory': postgres_runtime.canonical_path('fixture-data'),
|
|
'database': 'truf', 'user': 'truf', 'port': 55432, 'pg_major': 16,
|
|
'system_identifier': '123456789',
|
|
}
|
|
connection = mock.Mock()
|
|
connection.execute.return_value.fetchone.side_effect = [
|
|
{'database': 'truf', 'user_name': 'truf', 'port': 55432,
|
|
'data_directory': identity['data_directory'], 'in_recovery': False,
|
|
'postmaster_start_time': datetime.fromtimestamp(1700000000, timezone.utc)},
|
|
availability,
|
|
]
|
|
with mock.patch.object(postgres_runtime, 'database_url_from_env', return_value='fixture-dsn'), \
|
|
mock.patch.object(postgres_runtime, 'canonical_postgres_url', return_value='fixture-dsn'), \
|
|
mock.patch.object(postgres_runtime, 'connect_postgres', return_value=connection), \
|
|
mock.patch.object(backend, '_open_postmaster', return_value=process):
|
|
self.assertFalse(backend._online_query(identity)[0])
|
|
self.assertIsNone(backend._authenticated_postmaster)
|
|
backend._verified_identity = identity
|
|
backend._accepted_start_at_monotonic = None
|
|
backend._started_postmaster_observed = False
|
|
with mock.patch.object(backend, 'probe', return_value=ProbeResult(ProbeKind.RECOVERING)), \
|
|
mock.patch.object(backend, '_stop_retained_postmaster') as stop:
|
|
result = backend.stop()
|
|
self.assertFalse(result.completed or result.stopped)
|
|
stop.assert_not_called()
|
|
|
|
def test_recovery_stop_requires_the_same_authenticated_binding_and_handle(self):
|
|
for proof in ('none', 'changed-binding', 'different-process'):
|
|
with self.subTest(proof=proof):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
process = FakeRetainedPostmaster()
|
|
identity = {'system_identifier': '123', 'data_directory': 'fixture-data'}
|
|
backend._expected_process = process
|
|
backend._verified_identity = identity
|
|
backend._accepted_start_at_monotonic = None
|
|
backend._started_postmaster_observed = False
|
|
backend._authenticated_postmaster = {
|
|
'none': None,
|
|
'changed-binding': (dict(identity, system_identifier='456'), process),
|
|
'different-process': (identity, FakeRetainedPostmaster()),
|
|
}[proof]
|
|
with mock.patch.object(backend, 'probe', return_value=ProbeResult(ProbeKind.RECOVERING)), \
|
|
mock.patch.object(postgres_runtime, '_run_bounded') as run:
|
|
result = backend.stop()
|
|
self.assertFalse(result.completed or result.stopped)
|
|
self.assertFalse(process.closed)
|
|
run.assert_not_called()
|
|
|
|
def test_foreign_probe_cannot_stop_a_retained_accepted_start(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend._expected_process = FakeRetainedPostmaster()
|
|
backend._accepted_start_at_monotonic = postgres_runtime.time.monotonic()
|
|
backend._verified_identity = {'data_directory': 'fixture-data'}
|
|
backend._authenticated_postmaster = (backend._verified_identity, backend._expected_process)
|
|
with mock.patch.object(backend, 'probe', return_value=ProbeResult(ProbeKind.FOREIGN_OR_CONFIG_ERROR)), \
|
|
mock.patch.object(backend, '_stop_retained_postmaster') as stop:
|
|
result = backend.stop()
|
|
self.assertFalse(result.completed or result.stopped)
|
|
self.assertFalse(backend._expected_process.closed)
|
|
stop.assert_not_called()
|
|
|
|
def test_interrupted_close_does_not_poison_a_freshly_verified_handle(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
old = FakeRetainedPostmaster()
|
|
replacement = FakeRetainedPostmaster()
|
|
backend._expected_process = old
|
|
backend._authenticated_postmaster = ({'system_identifier': '123'}, old)
|
|
|
|
def interrupted_close():
|
|
old.closed = True
|
|
raise KeyboardInterrupt()
|
|
|
|
with mock.patch.object(old, 'close', side_effect=interrupted_close):
|
|
with self.assertRaises(KeyboardInterrupt):
|
|
backend.close()
|
|
self.assertIs(backend._expected_process, old)
|
|
self.assertIs(backend._remember_process(replacement), replacement)
|
|
self.assertFalse(replacement.closed)
|
|
self.assertIsNot(backend._authenticated_postmaster[1], replacement)
|
|
|
|
def test_interrupted_or_failed_launcher_retains_the_start_promise_for_compensation(self):
|
|
outcomes = (
|
|
KeyboardInterrupt(), SystemExit(0), OSError('launcher outcome unavailable'),
|
|
SimpleNamespace(returncode=1, stdout='launcher failed after possible fork'),
|
|
)
|
|
for outcome in outcomes:
|
|
with self.subTest(outcome=type(outcome).__name__):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.paths = {'log_path': 'fixture-postgres.log'}
|
|
backend._expected_process = None
|
|
backend._start_requested_wall_time = None
|
|
backend._accepted_start_at_monotonic = None
|
|
backend._started_postmaster_observed = False
|
|
backend._verified_identity = {
|
|
'data_directory': 'fixture-data', 'port': 55432,
|
|
'executables': {'pg_ctl': 'fixture-pg_ctl'},
|
|
}
|
|
self.assertFalse(backend.owns_start)
|
|
with mock.patch.object(backend, 'probe', return_value=ProbeResult(ProbeKind.STOPPED)), \
|
|
mock.patch.object(postgres_runtime, 'IS_WINDOWS', False), \
|
|
mock.patch.object(postgres_runtime, '_run_bounded') as run:
|
|
if isinstance(outcome, BaseException):
|
|
run.side_effect = outcome
|
|
else:
|
|
run.return_value = outcome
|
|
if isinstance(outcome, (KeyboardInterrupt, SystemExit)):
|
|
with self.assertRaises(type(outcome)):
|
|
backend.start()
|
|
else:
|
|
result = backend.start()
|
|
self.assertTrue(result.accepted)
|
|
self.assertTrue(result.uncertain)
|
|
self.assertTrue(backend.owns_start)
|
|
self.assertIsNotNone(backend._accepted_start_at_monotonic)
|
|
self.assertIsNotNone(backend._start_requested_wall_time)
|
|
|
|
def test_foreign_identity_stop_never_signals_or_closes_retained_process(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend._expected_process = FakeRetainedPostmaster()
|
|
backend._accepted_start_at_monotonic = None
|
|
with mock.patch.object(backend, 'probe', return_value=ProbeResult(ProbeKind.FOREIGN_OR_CONFIG_ERROR, 'foreign')), \
|
|
mock.patch.object(postgres_runtime, '_run_bounded') as run, \
|
|
mock.patch.object(postgres_runtime, 'open_process') as lookup:
|
|
result = backend.stop()
|
|
self.assertFalse(result.completed)
|
|
self.assertFalse(result.stopped)
|
|
self.assertIn('foreign', result.detail)
|
|
self.assertFalse(backend._expected_process.closed)
|
|
run.assert_not_called()
|
|
lookup.assert_not_called()
|
|
|
|
def test_pg_ctl_start_timeout_retains_uncertain_start_ownership(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.paths = {'log_path': r'D:\logs\postgres.log'}
|
|
backend._start_requested_wall_time = None
|
|
backend._accepted_start_at_monotonic = None
|
|
backend._started_postmaster_observed = False
|
|
backend._verified_identity = {
|
|
'data_directory': r'D:\bound-data',
|
|
'executables': {'pg_ctl': r'D:\bin\pg_ctl.exe'},
|
|
'port': 5544,
|
|
}
|
|
with mock.patch.object(backend, 'probe', return_value=ProbeResult(ProbeKind.STOPPED, 'offline')), \
|
|
mock.patch.object(postgres_runtime, 'IS_WINDOWS', False), \
|
|
mock.patch('postgres_runtime._run_bounded', side_effect=subprocess.TimeoutExpired('pg_ctl', 30)):
|
|
result = backend.start()
|
|
self.assertTrue(result.accepted)
|
|
self.assertTrue(result.uncertain)
|
|
self.assertIsNotNone(backend._accepted_start_at_monotonic)
|
|
self.assertIsNotNone(backend._start_requested_wall_time)
|
|
|
|
def test_shutdown_waits_for_late_postmaster_after_start_timeout_and_identity_stops_it(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.stop_timeout_sec = 5
|
|
backend.start_settle_timeout_sec = 0.1
|
|
backend._accepted_start_at_monotonic = postgres_runtime.time.monotonic()
|
|
backend._start_requested_wall_time = postgres_runtime.time.time()
|
|
backend._started_postmaster_observed = False
|
|
backend._expected_process = None
|
|
process = StoppableRetainedPostmaster()
|
|
identity = {
|
|
'data_directory': r'D:\bound-data',
|
|
'executables': {'pg_ctl': r'D:\bin\pg_ctl.exe'},
|
|
}
|
|
backend._verified_identity = identity
|
|
probes = {'count': 0}
|
|
|
|
def late_probe():
|
|
probes['count'] += 1
|
|
if probes['count'] >= 2:
|
|
backend._expected_process = process
|
|
return ProbeResult(ProbeKind.RECOVERING, 'uncertain start')
|
|
|
|
def stopped_after_command(*_args, **_kwargs):
|
|
process.running = False
|
|
return SimpleNamespace(returncode=0, stdout='')
|
|
|
|
with mock.patch.object(backend, 'probe', side_effect=late_probe), \
|
|
mock.patch('postgres_runtime._parse_postmaster_pid', return_value=process.pid), \
|
|
mock.patch('postgres_runtime._run_bounded', side_effect=stopped_after_command):
|
|
result = backend.stop()
|
|
self.assertTrue(result.completed)
|
|
self.assertTrue(result.stopped)
|
|
self.assertGreaterEqual(probes['count'], 2)
|
|
|
|
def test_stop_reprobes_recent_accepted_start_then_identity_stops_late_postmaster(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.stop_timeout_sec = 5
|
|
backend.start_settle_timeout_sec = 1
|
|
backend._accepted_start_at_monotonic = postgres_runtime.time.monotonic()
|
|
backend._start_requested_wall_time = postgres_runtime.time.time()
|
|
process = StoppableRetainedPostmaster()
|
|
backend._expected_process = process
|
|
backend._verified_identity = {
|
|
'data_directory': r'D:\bound-data',
|
|
'executables': {'pg_ctl': r'D:\bin\pg_ctl.exe'},
|
|
}
|
|
|
|
def stopped_after_command(*_args, **_kwargs):
|
|
process.running = False
|
|
return SimpleNamespace(returncode=0, stdout='')
|
|
|
|
with mock.patch.object(backend, 'probe', side_effect=[
|
|
ProbeResult(ProbeKind.STOPPED, 'not published yet'),
|
|
ProbeResult(ProbeKind.READY, 'late postmaster ready'),
|
|
]), mock.patch('postgres_runtime._parse_postmaster_pid', return_value=process.pid), \
|
|
mock.patch('postgres_runtime._run_bounded', side_effect=stopped_after_command):
|
|
result = backend.stop()
|
|
self.assertTrue(result.completed)
|
|
self.assertTrue(result.stopped)
|
|
self.assertTrue(process.closed)
|
|
|
|
def test_stop_reports_uncertain_when_recent_accepted_start_never_publishes(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.start_settle_timeout_sec = 0.001
|
|
backend._accepted_start_at_monotonic = postgres_runtime.time.monotonic()
|
|
backend._start_requested_wall_time = postgres_runtime.time.time()
|
|
backend._expected_process = None
|
|
backend._verified_identity = None
|
|
with mock.patch.object(backend, 'probe', return_value=ProbeResult(ProbeKind.STOPPED, 'offline')):
|
|
result = backend.stop()
|
|
self.assertFalse(result.completed)
|
|
self.assertFalse(result.stopped)
|
|
self.assertIn('uncertain', result.detail)
|
|
|
|
def test_restart_during_local_recovery_is_adopted_as_recovering(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.config = {}
|
|
backend.paths = {}
|
|
backend._expected_process = None
|
|
backend._start_requested_wall_time = None
|
|
backend._verified_identity = None
|
|
identity = {
|
|
'data_directory': r'D:\bound-data',
|
|
'executables': {'postgres': r'D:\bin\postgres.exe'},
|
|
'port': 5432,
|
|
}
|
|
process = FakeRetainedPostmaster()
|
|
with mock.patch.object(backend, '_root_job_error', return_value=None), \
|
|
mock.patch('postgres_runtime.verify_cluster_identity', return_value=identity), \
|
|
mock.patch.object(backend, '_online_query', side_effect=OnlineUnavailable('not query-ready')), \
|
|
mock.patch('postgres_runtime._parse_postmaster_pid', return_value=process.pid), \
|
|
mock.patch.object(backend, '_open_postmaster', return_value=process), \
|
|
mock.patch('postgres_runtime._listener_present', return_value=True):
|
|
result = backend.probe()
|
|
self.assertEqual(result.kind, ProbeKind.RECOVERING)
|
|
self.assertIs(backend._expected_process, process)
|
|
self.assertIn('awaiting authenticated identity', result.detail)
|
|
|
|
def test_postmaster_adoption_requires_pid_file_data_directory_and_start_time(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
data_dir = os.path.join(temp_dir, 'data')
|
|
os.makedirs(data_dir)
|
|
start_time = 1700000000
|
|
Path(os.path.join(data_dir, 'postmaster.pid')).write_text(
|
|
f'321\n{data_dir}\n{start_time}\n',
|
|
encoding='ascii',
|
|
)
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend._start_requested_wall_time = None
|
|
process = mock.Mock()
|
|
process.identity = SimpleNamespace(
|
|
executable=postgres_runtime.canonical_path(os.path.join(temp_dir, 'postgres.exe')),
|
|
in_job=False,
|
|
creation_time_unix=float(start_time),
|
|
)
|
|
identity = {
|
|
'data_directory': postgres_runtime.canonical_path(data_dir),
|
|
'executables': {'postgres': process.identity.executable},
|
|
}
|
|
with mock.patch('postgres_runtime.open_process', return_value=process):
|
|
self.assertIs(backend._open_postmaster(identity), process)
|
|
|
|
Path(os.path.join(data_dir, 'postmaster.pid')).write_text(
|
|
f'321\n{os.path.join(temp_dir, "other")}\n{start_time}\n',
|
|
encoding='ascii',
|
|
)
|
|
with self.assertRaisesRegex(ClusterIdentityError, 'data directory'):
|
|
backend._open_postmaster(identity)
|
|
|
|
def test_linux_postmaster_adoption_ignores_wall_clock_skew(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
data_dir = os.path.join(temp_dir, 'data')
|
|
os.makedirs(data_dir)
|
|
start_time = 1700000000
|
|
Path(os.path.join(data_dir, 'postmaster.pid')).write_text(
|
|
f'321\n{data_dir}\n{start_time}\n',
|
|
encoding='ascii',
|
|
)
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend._start_requested_wall_time = None
|
|
process = mock.Mock()
|
|
process.identity = SimpleNamespace(
|
|
executable=postgres_runtime.canonical_path(os.path.join(temp_dir, 'postgres')),
|
|
in_job=False,
|
|
creation_time_unix=float(start_time) - 22.0,
|
|
)
|
|
identity = {
|
|
'data_directory': postgres_runtime.canonical_path(data_dir),
|
|
'executables': {'postgres': process.identity.executable},
|
|
}
|
|
with mock.patch.object(postgres_runtime, 'IS_WINDOWS', False), \
|
|
mock.patch('postgres_runtime.open_process', return_value=process):
|
|
self.assertIs(backend._open_postmaster(identity), process)
|
|
|
|
def test_windows_postmaster_adoption_rejects_wall_clock_skew(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
data_dir = os.path.join(temp_dir, 'data')
|
|
os.makedirs(data_dir)
|
|
start_time = 1700000000
|
|
Path(os.path.join(data_dir, 'postmaster.pid')).write_text(
|
|
f'321\n{data_dir}\n{start_time}\n',
|
|
encoding='ascii',
|
|
)
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend._start_requested_wall_time = None
|
|
process = mock.Mock()
|
|
process.identity = SimpleNamespace(
|
|
executable=postgres_runtime.canonical_path(os.path.join(temp_dir, 'postgres.exe')),
|
|
in_job=False,
|
|
creation_time_unix=float(start_time) - 22.0,
|
|
)
|
|
identity = {
|
|
'data_directory': postgres_runtime.canonical_path(data_dir),
|
|
'executables': {'postgres': process.identity.executable},
|
|
}
|
|
with mock.patch.object(postgres_runtime, 'IS_WINDOWS', True), \
|
|
mock.patch('postgres_runtime.open_process', return_value=process):
|
|
with self.assertRaisesRegex(ClusterIdentityError, 'start time does not match'):
|
|
backend._open_postmaster(identity)
|
|
|
|
def test_linux_online_query_ignores_postmaster_wall_clock_skew(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.connect_timeout_sec = 1
|
|
backend.query_timeout_ms = 1000
|
|
backend._expected_process = None
|
|
backend._authenticated_postmaster = None
|
|
process = FakeRetainedPostmaster()
|
|
process.identity.creation_time_unix = 1700000000.0 - 22.0
|
|
identity = {
|
|
'data_directory': postgres_runtime.canonical_path('fixture-data'),
|
|
'database': 'truf', 'user': 'truf', 'port': 55432, 'pg_major': 16,
|
|
'system_identifier': '123456789',
|
|
}
|
|
connection = mock.Mock()
|
|
connection.execute.return_value.fetchone.side_effect = [
|
|
{'database': 'truf', 'user_name': 'truf', 'port': 55432,
|
|
'data_directory': identity['data_directory'], 'in_recovery': False,
|
|
'postmaster_start_time': datetime.fromtimestamp(1700000000, timezone.utc)},
|
|
{'present': True, 'permitted': True}, {'system_identifier': '123456789'},
|
|
]
|
|
with mock.patch.object(postgres_runtime, 'IS_WINDOWS', False), \
|
|
mock.patch.object(postgres_runtime, 'database_url_from_env', return_value='fixture-dsn'), \
|
|
mock.patch.object(postgres_runtime, 'canonical_postgres_url', return_value='fixture-dsn'), \
|
|
mock.patch.object(postgres_runtime, 'connect_postgres', return_value=connection), \
|
|
mock.patch.object(backend, '_open_postmaster', return_value=process):
|
|
in_recovery, _epoch = backend._online_query(identity)
|
|
self.assertFalse(in_recovery)
|
|
self.assertEqual(backend._authenticated_postmaster, (identity, process))
|
|
|
|
def test_windows_online_query_rejects_postmaster_wall_clock_skew(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.connect_timeout_sec = 1
|
|
backend.query_timeout_ms = 1000
|
|
backend._expected_process = None
|
|
backend._authenticated_postmaster = None
|
|
process = FakeRetainedPostmaster()
|
|
process.identity.creation_time_unix = 1700000000.0 - 22.0
|
|
identity = {
|
|
'data_directory': postgres_runtime.canonical_path('fixture-data'),
|
|
'database': 'truf', 'user': 'truf', 'port': 55432, 'pg_major': 16,
|
|
'system_identifier': '123456789',
|
|
}
|
|
connection = mock.Mock()
|
|
connection.execute.return_value.fetchone.side_effect = [
|
|
{'database': 'truf', 'user_name': 'truf', 'port': 55432,
|
|
'data_directory': identity['data_directory'], 'in_recovery': False,
|
|
'postmaster_start_time': datetime.fromtimestamp(1700000000, timezone.utc)},
|
|
{'present': True, 'permitted': True}, {'system_identifier': '123456789'},
|
|
]
|
|
with mock.patch.object(postgres_runtime, 'IS_WINDOWS', True), \
|
|
mock.patch.object(postgres_runtime, 'database_url_from_env', return_value='fixture-dsn'), \
|
|
mock.patch.object(postgres_runtime, 'canonical_postgres_url', return_value='fixture-dsn'), \
|
|
mock.patch.object(postgres_runtime, 'connect_postgres', return_value=connection), \
|
|
mock.patch.object(backend, '_open_postmaster', return_value=process):
|
|
with self.assertRaisesRegex(ClusterIdentityError, 'creation time mismatch'):
|
|
backend._online_query(identity)
|
|
self.assertIsNone(backend._authenticated_postmaster)
|
|
|
|
def test_pg_ctl_start_forces_bound_data_directory(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.paths = {'log_path': r'D:\logs\postgres.log'}
|
|
backend._start_requested_wall_time = None
|
|
identity = {
|
|
'data_directory': r'D:\bound data',
|
|
'executables': {'pg_ctl': r'D:\bin\pg_ctl.exe'},
|
|
'port': 5544,
|
|
}
|
|
backend._verified_identity = identity
|
|
with mock.patch.object(backend, 'probe', return_value=ProbeResult(ProbeKind.STOPPED, 'offline')), \
|
|
mock.patch.object(postgres_runtime, 'IS_WINDOWS', False), \
|
|
mock.patch('postgres_runtime._run_bounded', return_value=SimpleNamespace(returncode=0, stdout='')) as run:
|
|
result = backend.start()
|
|
self.assertTrue(result.accepted)
|
|
self.assertFalse(run.call_args.kwargs['capture_output'])
|
|
command = run.call_args.args[0]
|
|
options = command[command.index('-o') + 1]
|
|
self.assertIn('data_directory=', options)
|
|
self.assertIn(r'D:\bound data', options)
|
|
|
|
def test_pg_ctl_start_forces_collector_rotation_options(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
postgres_dir = os.path.join(temp_dir, 'postgres')
|
|
log_dir = os.path.join(postgres_dir, 'logs')
|
|
os.makedirs(postgres_dir)
|
|
ensure_private_directory(temp_dir, reject_reparse=True)
|
|
ensure_private_directory(postgres_dir, reject_reparse=True)
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.paths = {
|
|
'log_path': os.path.join(postgres_dir, 'postgres.log'),
|
|
'log_dir': log_dir,
|
|
}
|
|
backend.log_max_mb = 7
|
|
backend.log_keep = 3
|
|
backend._start_requested_wall_time = None
|
|
backend._accepted_start_at_monotonic = None
|
|
backend._started_postmaster_observed = False
|
|
identity = {
|
|
'data_directory': os.path.join(temp_dir, 'data'),
|
|
'executables': {'pg_ctl': os.path.join(temp_dir, 'pg_ctl.exe')},
|
|
'port': 5544,
|
|
}
|
|
backend._verified_identity = identity
|
|
with mock.patch.object(backend, 'probe', return_value=ProbeResult(ProbeKind.STOPPED, 'offline')), \
|
|
mock.patch.object(postgres_runtime, 'IS_WINDOWS', False), \
|
|
mock.patch('postgres_runtime._run_bounded', return_value=SimpleNamespace(returncode=0, stdout='')) as run:
|
|
result = backend.start()
|
|
self.assertTrue(result.accepted)
|
|
options = run.call_args.args[0][run.call_args.args[0].index('-o') + 1]
|
|
self.assertIn('logging_collector=on', options)
|
|
self.assertIn('log_rotation_size=7MB', options)
|
|
self.assertIn('log_truncate_on_rotation=on', options)
|
|
|
|
def test_postgres_collector_retention_prunes_old_private_segments(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
ensure_private_directory(temp_dir, reject_reparse=True)
|
|
paths = []
|
|
for index in range(5):
|
|
path = os.path.join(temp_dir, f'postgresql-20260719-00000{index}.log')
|
|
Path(path).write_text('x', encoding='ascii')
|
|
postgres_runtime.harden_private_file(path)
|
|
os.utime(path, (index + 1, index + 1))
|
|
paths.append(path)
|
|
removed = postgres_runtime.prune_postgres_collector_logs(temp_dir, 2)
|
|
self.assertEqual(removed, 3)
|
|
self.assertEqual(sum(os.path.exists(path) for path in paths), 2)
|
|
|
|
def test_pg_ctl_success_is_not_stopped_while_retained_process_is_live(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.stop_timeout_sec = 5
|
|
backend._verified_identity = {
|
|
'data_directory': r'D:\bound-data',
|
|
'executables': {'pg_ctl': r'D:\bin\pg_ctl.exe'},
|
|
}
|
|
backend._expected_process = FakeRetainedPostmaster(pid=321)
|
|
with mock.patch.object(backend, 'probe', return_value=ProbeResult(ProbeKind.READY, 'ready')), \
|
|
mock.patch('postgres_runtime._parse_postmaster_pid', return_value=321), \
|
|
mock.patch('postgres_runtime._run_bounded', return_value=SimpleNamespace(returncode=0, stdout='')):
|
|
result = backend.stop()
|
|
self.assertFalse(result.stopped)
|
|
self.assertIn('still running', result.detail)
|
|
|
|
def test_pg_ctl_timeout_is_reported_as_not_stopped(self):
|
|
backend = PostgresBackend.__new__(PostgresBackend)
|
|
backend.stop_timeout_sec = 5
|
|
backend._verified_identity = {
|
|
'data_directory': r'D:\bound-data',
|
|
'executables': {'pg_ctl': r'D:\bin\pg_ctl.exe'},
|
|
}
|
|
backend._expected_process = FakeRetainedPostmaster(pid=321)
|
|
with mock.patch.object(backend, 'probe', return_value=ProbeResult(ProbeKind.READY, 'ready')), \
|
|
mock.patch('postgres_runtime._parse_postmaster_pid', return_value=321), \
|
|
mock.patch('postgres_runtime._run_bounded', side_effect=TimeoutError('timed out')):
|
|
result = backend.stop()
|
|
self.assertFalse(result.completed)
|
|
self.assertFalse(result.stopped)
|
|
self.assertIn('timed out', result.detail)
|
|
|
|
|
|
class PostgresEntrypointTests(unittest.TestCase):
|
|
def test_preflight_failure_precedes_environment_and_cluster_tools(self):
|
|
config = {'global': {'runtime_dir': r'D:\fixture\runtime'}}
|
|
with mock.patch.object(sys, 'argv', ['postgres_runtime.py', 'verify', '--config', 'config.yaml']), \
|
|
mock.patch.object(postgres_runtime, '_load_config', return_value=config), \
|
|
mock.patch.object(postgres_runtime, 'preflight_lifecycle_paths', side_effect=OSError('unsafe config')) as preflight, \
|
|
mock.patch.object(postgres_runtime, 'load_postgres_environment') as load_environment, \
|
|
mock.patch.object(postgres_runtime, 'verify_cluster_identity') as verify:
|
|
with self.assertRaisesRegex(SystemExit, 'unsafe config'):
|
|
postgres_runtime.main()
|
|
load_environment.assert_not_called()
|
|
verify.assert_not_called()
|
|
preflight.assert_called_once_with(
|
|
os.path.abspath('config.yaml'), config, authority_profile='server',
|
|
)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|