Smail/test/deployment/resource_policy_test.py
smail 3532f76c66
Some checks are pending
Security Checks / security (push) Waiting to run
补充生产服务资源防护与只读预检并记录远程验收阻塞
2026-09-17 15:39:28 +08:00

270 lines
14 KiB
Python

"""资源预检的反例和部署策略回归;仅在批准的远程测试环境运行。"""
import configparser
import importlib.util
import io
import json
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
SPEC = importlib.util.spec_from_file_location('check_resources', ROOT / 'deploy/check_resources.py')
RESOURCES = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(RESOURCES)
MIB = RESOURCES.MIB
def snapshot():
return {
'stable': True, 'go_memory_bytes': 256 * MIB,
'host_available_bytes': 1024 * MIB, 'host_total_bytes': 2048 * MIB,
'oom': {'oom': 0, 'oom_kill': 0}, 'parents': [],
'service': {
'MemoryHigh': str(384 * MIB), 'MemoryMax': str(512 * MIB),
'MemorySwapMax': '0', 'TasksMax': '256', 'OOMPolicy': 'stop',
'KillMode': 'control-group', 'Restart': 'on-failure',
'RestartUSec': '10s', 'StartLimitIntervalUSec': '5min', 'StartLimitBurst': '3',
'TimeoutStopUSec': '1min 30s',
},
'kernel': {
'memory.high': str(384 * MIB), 'memory.max': str(512 * MIB),
'memory.current': str(128 * MIB), 'memory.swap.max': '0',
'pids.max': '256', 'pids.current': '32', 'cpu.max': '100000 100000',
},
}
class ResourcePolicyTests(unittest.TestCase):
def test_valid_budget_is_not_production_approval(self):
result = RESOURCES.evaluate(snapshot(), 256 * MIB)
self.assertTrue(result['resource_checks_passed'])
self.assertFalse(result['production_approved'])
self.assertEqual(result['remaining_growth_bytes'], 384 * MIB)
self.assertEqual(result['failures'], [])
def test_missing_and_unsafe_limits_fail_closed(self):
cases = [
('kernel', 'memory.high', 'max'),
('kernel', 'memory.max', 'max'),
('kernel', 'memory.high', str(512 * MIB)),
('kernel', 'memory.high', '0'),
('kernel', 'memory.current', str(384 * MIB)),
('kernel', 'memory.swap.max', 'max'),
('kernel', 'memory.swap.max', str(MIB)),
('kernel', 'pids.max', 'max'),
('kernel', 'pids.max', '0'),
('kernel', 'pids.current', '256'),
('kernel', 'cpu.max', 'max 100000'),
('service', 'MemoryMax', 'infinity'),
('service', 'MemoryHigh', str(256 * MIB)),
('service', 'MemorySwapMax', 'infinity'),
('service', 'TasksMax', '512'),
('service', 'OOMPolicy', 'continue'),
('service', 'KillMode', 'process'),
('service', 'TimeoutStopUSec', '60s'),
('service', 'TimeoutStopUSec', 'infinity'),
('service', 'Restart', 'always'),
('service', 'RestartUSec', '100ms'),
('service', 'RestartUSec', 'infinity'),
('service', 'StartLimitIntervalUSec', '0'),
('service', 'StartLimitIntervalUSec', '5s'),
('service', 'StartLimitBurst', '0'),
('service', 'StartLimitBurst', '6'),
('oom', 'oom', 1),
('oom', 'oom_kill', 1),
]
for section, key, value in cases:
with self.subTest(section=section, key=key, value=value):
candidate = snapshot()
candidate[section][key] = value
report = RESOURCES.evaluate(candidate, 256 * MIB)
self.assertFalse(report['resource_checks_passed'])
self.assertTrue(report['failures'])
def test_process_budget_and_restart_race_fail_closed(self):
for key, values in {
'go_memory_bytes': [None, 0, 63 * MIB, 384 * MIB, 1024 * MIB],
'stable': [False],
'host_available_bytes': [128 * MIB, 512 * MIB],
'host_total_bytes': [512 * MIB],
}.items():
for value in values:
with self.subTest(key=key, value=value):
candidate = snapshot()
candidate[key] = value
self.assertFalse(RESOURCES.evaluate(candidate, 256 * MIB)['resource_checks_passed'])
self.assertFalse(RESOURCES.evaluate(snapshot(), 0)['resource_checks_passed'])
def test_ancestor_limits_cannot_be_ignored(self):
baseline = {'memory.high': 'max', 'memory.max': 'max', 'memory.current': str(256 * MIB),
'pids.max': 'max', 'pids.current': '64', 'cpu.max': 'max 100000'}
cases = [('memory.high', str(768 * MIB)), ('memory.max', str(768 * MIB)),
('pids.max', '256'), ('cpu.max', '50000 100000')]
for key, value in cases:
with self.subTest(key=key):
candidate = snapshot()
limits = {**baseline, key: value}
candidate['parents'] = [{'path': 'system.slice', 'limits': limits}]
result = RESOURCES.evaluate(candidate, 256 * MIB)
self.assertFalse(result['resource_checks_passed'])
self.assertTrue(any('system.slice' in failure for failure in result['failures']))
candidate = snapshot()
candidate['parents'] = [{'path': 'system.slice', 'limits': baseline}]
self.assertTrue(RESOURCES.evaluate(candidate, 256 * MIB)['resource_checks_passed'])
def test_environment_parser_never_returns_credentials(self):
environment = b'SMAIL_SECRET=must-not-appear\0GOMEMLIMIT=256MiB\0TOKEN=also-secret\0'
self.assertEqual(RESOURCES.go_memory_limit(environment), 256 * MIB)
for value in (b'', b'off', b'256MB', b'1.5GiB', b'secret', b'-1', b' 256MiB', b'\xff'):
with self.subTest(value=value):
self.assertIsNone(RESOURCES.go_memory_limit(b'GOMEMLIMIT=' + value + b'\0'))
self.assertIsNone(RESOURCES.go_memory_limit(environment + b'GOMEMLIMIT=128MiB\0'))
self.assertIsNone(RESOURCES.go_memory_limit(b'TOKEN=secret\0'))
self.assertEqual(RESOURCES.go_memory_limit(b'GOMEMLIMIT=1024\0'), 1024)
self.assertEqual(RESOURCES.go_memory_limit(b'GOMEMLIMIT=1GiB\0'), 1024 * MIB)
def test_duration_and_cpu_parsing(self):
self.assertEqual(RESOURCES.duration_usec('5min'), 300000000)
self.assertEqual(RESOURCES.duration_usec('1min 2.5s'), 62500000)
self.assertEqual(RESOURCES.duration_usec('500ms'), 500000)
for value in ('infinity', 'junk10s', '10s junk', '-1s', ''):
self.assertIsNone(RESOURCES.duration_usec(value))
self.assertEqual(RESOURCES.cpu_quota('25000 100000'), 0.25)
self.assertIsNone(RESOURCES.cpu_quota('max 100000'))
for value in ('0 100000', '100000 0', '-1 100000', 'unknown'):
with self.assertRaises(ValueError):
RESOURCES.cpu_quota(value)
def test_systemd_query_is_read_only_and_does_not_request_environment(self):
output = '\n'.join(name + '=value' for name in RESOURCES.PROPERTIES)
output += '\nEnvironment=TOKEN=must-not-appear'
with patch.object(RESOURCES.subprocess, 'run', return_value=SimpleNamespace(stdout=output)) as run:
result = RESOURCES.systemd_state()
self.assertEqual(set(result), set(RESOURCES.PROPERTIES))
self.assertNotIn('must-not-appear', json.dumps(result))
command = run.call_args.args[0]
self.assertEqual(command[:3], ['systemctl', 'show', 'smail.service'])
self.assertNotIn('Environment', command[-1])
self.assertNotIn('ExecStart', command[-1])
self.assertNotIn('shell', run.call_args.kwargs)
with patch.object(RESOURCES.subprocess, 'run', return_value=SimpleNamespace(stdout='MainPID=1')):
with self.assertRaises(ValueError):
RESOURCES.systemd_state()
def test_collect_refuses_inactive_service_before_reading_process(self):
with patch.object(RESOURCES, 'systemd_state', return_value={
'MainPID': '0', 'ActiveState': 'inactive', 'SubState': 'dead',
}):
with self.assertRaises(ValueError):
RESOURCES.collect()
def collection_fixture(self):
root = Path(tempfile.mkdtemp(prefix='smail-resource-'))
base = root / 'sys/fs/cgroup'
group = base / 'system.slice/smail.service'
proc = root / 'proc/123'
group.mkdir(parents=True)
proc.mkdir(parents=True)
for name, value in snapshot()['kernel'].items():
(group / name).write_text(value, encoding='ascii')
(group / 'memory.events').write_text('oom 0\noom_kill 0\n', encoding='ascii')
(proc / 'cgroup').write_text('0::/system.slice/smail.service\n', encoding='ascii')
(proc / 'environ').write_bytes(b'GOMEMLIMIT=256MiB\0TOKEN=must-not-appear\0')
(root / 'proc/meminfo').write_text('MemAvailable: 1048576 kB\nMemTotal: 2097152 kB\n',
encoding='ascii')
state = {**snapshot()['service'], 'MainPID': '123', 'NRestarts': '0',
'InvocationID': 'fixture', 'ActiveState': 'active', 'SubState': 'running',
'ControlGroup': '/system.slice/smail.service'}
def locate(value):
path = Path(value)
if str(path).startswith('/proc') or str(path).startswith('/sys/fs/cgroup'):
return root / str(path).lstrip('/')
return path
return root, state, locate
def test_collect_reads_kernel_and_only_the_go_environment(self):
root, state, locate = self.collection_fixture()
with patch.object(RESOURCES, 'systemd_state', side_effect=[state, state.copy()]), \
patch.object(RESOURCES, 'Path', side_effect=locate):
result = RESOURCES.collect()
self.assertTrue(result['stable'])
self.assertEqual(result['go_memory_bytes'], 256 * MIB)
self.assertEqual(result['host_available_bytes'], 1024 * MIB)
self.assertNotIn('must-not-appear', json.dumps(result))
self.assertEqual([parent['path'] for parent in result['parents']], ['system.slice', '.'])
self.assertTrue(RESOURCES.evaluate(result, 256 * MIB)['resource_checks_passed'])
def test_collect_detects_service_restart(self):
root, state, locate = self.collection_fixture()
changed = {**state, 'InvocationID': 'new-process'}
with patch.object(RESOURCES, 'systemd_state', side_effect=[state, changed]), \
patch.object(RESOURCES, 'Path', side_effect=locate):
result = RESOURCES.collect()
self.assertFalse(result['stable'])
self.assertFalse(RESOURCES.evaluate(result, 256 * MIB)['resource_checks_passed'])
def test_collect_refuses_pid_in_another_group(self):
root, state, locate = self.collection_fixture()
(root / 'proc/123/cgroup').write_text('0::/system.slice/other.service\n', encoding='ascii')
with patch.object(RESOURCES, 'systemd_state', return_value=state), \
patch.object(RESOURCES, 'Path', side_effect=locate):
with self.assertRaises(ValueError):
RESOURCES.collect()
def test_cli_redacts_collection_errors_and_fails(self):
output = io.StringIO()
with patch('sys.argv', ['check_resources.py', '--reserve-mib', '256']), \
patch.object(RESOURCES.os, 'name', 'posix'), \
patch.object(RESOURCES.os, 'geteuid', return_value=0, create=True), \
patch.object(RESOURCES, 'collect', side_effect=OSError('TOKEN=must-not-appear')), \
patch('sys.stdout', output):
status = RESOURCES.main()
self.assertEqual(status, 1)
self.assertNotIn('TOKEN', output.getvalue())
self.assertFalse(json.loads(output.getvalue())['production_approved'])
def test_cli_reports_success_without_production_approval(self):
output = io.StringIO()
with patch('sys.argv', ['check_resources.py', '--reserve-mib', '256']), \
patch.object(RESOURCES.os, 'name', 'posix'), \
patch.object(RESOURCES.os, 'geteuid', return_value=0, create=True), \
patch.object(RESOURCES, 'collect', return_value=snapshot()), \
patch('sys.stdout', output):
self.assertEqual(RESOURCES.main(), 0)
self.assertTrue(json.loads(output.getvalue())['resource_checks_passed'])
self.assertFalse(json.loads(output.getvalue())['production_approved'])
def test_deployment_unit_has_bounded_resources_and_restart_policy(self):
unit = configparser.ConfigParser(interpolation=None)
unit.read(ROOT / 'deploy/systemd/smail.service', encoding='utf-8')
values = unit['Service']
for key, value in {'MemoryHigh': '384M', 'MemoryMax': '512M', 'MemorySwapMax': '0',
'CPUQuota': '100%', 'TasksMax': '256', 'OOMPolicy': 'stop',
'KillMode': 'control-group', 'Restart': 'on-failure',
'RestartSec': '10s', 'TimeoutStopSec': '90s',
'MemoryAccounting': 'true', 'CPUAccounting': 'true',
'TasksAccounting': 'true'}.items():
self.assertEqual(values[key], value)
self.assertEqual(unit['Unit']['StartLimitIntervalSec'], '300s')
self.assertEqual(unit['Unit']['StartLimitBurst'], '3')
def test_installer_preserves_existing_environment_budget(self):
source = (ROOT / 'deploy/install-systemd.sh').read_text(encoding='utf-8')
guard = source.index('if [ ! -e /etc/smail/smail.env ]; then')
end = source.index('\nfi', guard)
assignment = source.index("'GOMEMLIMIT=256MiB'")
self.assertTrue(guard < assignment < end)
self.assertEqual(source.count('GOMEMLIMIT='), 1)
self.assertIn('chmod 0600 /etc/smail/smail.env', source)
self.assertIn('cp -p -- /etc/smail/smail.env "$backup_dir/smail.env"', source)
if __name__ == '__main__':
unittest.main(verbosity=2)