mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-03 02:26:43 +09:00
Toasts showed backend prose verbatim, with no code to translate against and "application" where the UI says "service". Not-found, missing-field, permission, conflict and agent-offline errors now come from factories in app/exceptions.py that pair one fixed message with a dotted code (not_found.<resource>, validation.required, permission.denied, conflict.exists, agent.offline), so the client can translate them. The shared app-loading helpers and the apps, domains, servers, databases and backups routes raise those errors instead of hand shaping bodies, which drops the hand-shaped body count from 1132 to 850. Five routes picked an HTTP status by comparing error text; they now key off a result code or a sentinel so rewording can't change a status. Five broad excepts that echoed str(exc) now log the crash and return the request id instead.
136 lines
5.9 KiB
Python
136 lines
5.9 KiB
Python
"""Database insights parsing and the pg_stat_statements switch (plan 86 §A3).
|
|
|
|
Hermetic: SQL is scripted through the services' exec seams. The real-engine
|
|
round trip is test_real_db_insights_docker.py.
|
|
"""
|
|
import pytest
|
|
|
|
from app.exceptions import ValidationError
|
|
from app.services.db_config_tuner_service import DbConfigTunerService
|
|
from app.services.db_insights_service import DbInsightsService
|
|
|
|
PG = {'engine': 'postgresql', 'container': 'pg'}
|
|
MY = {'engine': 'mariadb', 'container': 'db'}
|
|
|
|
|
|
def _script(monkeypatch, answers, cls=DbInsightsService):
|
|
seen = []
|
|
|
|
def fake(target, sql):
|
|
seen.append(sql)
|
|
for needle, result in answers:
|
|
if needle in sql:
|
|
return result
|
|
raise AssertionError(f'unscripted SQL: {sql}')
|
|
monkeypatch.setattr(cls, '_exec_sql', staticmethod(fake) if cls is DbInsightsService
|
|
else classmethod(lambda c, t, s: fake(t, s)))
|
|
return seen
|
|
|
|
|
|
def ok(output=''):
|
|
return {'success': True, 'output': output, 'error': None}
|
|
|
|
|
|
def fail(error='boom'):
|
|
return {'success': False, 'output': '', 'error': error}
|
|
|
|
|
|
def test_postgres_without_the_extension_says_what_turns_it_on(monkeypatch):
|
|
_script(monkeypatch, [('pg_stat_database', ok('100\t7\t990\t10\t0\t0\n'))])
|
|
result = DbInsightsService.insights(PG)
|
|
assert result['connections'] == {'in_use': 7, 'max': 100}
|
|
assert result['cache_hit_ratio'] == pytest.approx(0.99)
|
|
assert result['top_queries_available'] is False
|
|
assert result['top_queries_reason'] == 'pg_stat_statements is not enabled'
|
|
assert result['can_enable'] is True
|
|
|
|
|
|
def test_postgres_top_queries_parse_even_with_tabs_in_sql(monkeypatch):
|
|
_script(monkeypatch, [
|
|
('pg_stat_database', ok('100\t3\t5\t5\t1\t1\n')),
|
|
('FROM pg_stat_statements', ok('42\t1234.5\t29.39\t42\tSELECT a\tb FROM t\n')),
|
|
])
|
|
(q,) = DbInsightsService.insights(PG)['top_queries']
|
|
assert q == {'calls': 42, 'total_ms': 1234.5, 'mean_ms': 29.39, 'rows': 42,
|
|
'query': 'SELECT a\tb FROM t'}
|
|
|
|
|
|
def test_mysql_hit_ratio_and_a_closed_performance_schema(monkeypatch):
|
|
_script(monkeypatch, [
|
|
('SHOW GLOBAL STATUS', ok('Innodb_buffer_pool_read_requests\t1000\n'
|
|
'Innodb_buffer_pool_reads\t50\nThreads_connected\t4\n')),
|
|
('@@max_connections', ok('151\n')),
|
|
('events_statements_summary_by_digest', fail("Table doesn't exist")),
|
|
])
|
|
result = DbInsightsService.insights(MY)
|
|
assert result['engine'] == 'mysql'
|
|
assert result['connections'] == {'in_use': 4, 'max': 151}
|
|
# 1000 logical reads, 50 of them missed and went to disk.
|
|
assert result['cache_hit_ratio'] == pytest.approx(950 / 1000)
|
|
assert result['top_queries_available'] is False
|
|
assert 'performance_schema' in result['top_queries_reason']
|
|
|
|
|
|
def test_empty_counters_mean_no_ratio_not_zero(monkeypatch):
|
|
_script(monkeypatch, [('pg_stat_database', ok('100\t1\t0\t0\t0\t0\n'))])
|
|
assert DbInsightsService.insights(PG)['cache_hit_ratio'] is None
|
|
|
|
|
|
@pytest.mark.parametrize('target,error', [
|
|
({'engine': 'sqlite', 'container': 'x'}, 'unsupported engine'),
|
|
({'engine': 'postgresql'}, 'insights need a Docker database container'),
|
|
])
|
|
def test_refused_targets(target, error):
|
|
assert DbInsightsService.insights(target)['error'] == error
|
|
|
|
|
|
class TestEnablePgStatStatements:
|
|
@pytest.fixture
|
|
def tuner(self, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(DbConfigTunerService, '_linux_supported', classmethod(lambda cls: True))
|
|
monkeypatch.setattr(DbConfigTunerService, 'STATE_DIR', str(tmp_path))
|
|
monkeypatch.setattr(DbConfigTunerService, '_snapshot_container_file',
|
|
classmethod(lambda cls, t, p, s: str(tmp_path / 'previous-1.auto.conf')))
|
|
restarts = []
|
|
monkeypatch.setattr(DbConfigTunerService, '_restart_and_verify',
|
|
classmethod(lambda cls, t, b, p, s: restarts.append(s) or
|
|
{'success': True, 'restarted': True}))
|
|
return restarts
|
|
|
|
def test_existing_preloads_are_kept(self, monkeypatch, tuner):
|
|
seen = _script(monkeypatch, [
|
|
('SHOW shared_preload_libraries', ok('auto_explain, timescaledb\n')),
|
|
('SHOW data_directory', ok('/var/lib/postgresql/data\n')),
|
|
('ALTER SYSTEM', ok()), ('CREATE EXTENSION', ok()),
|
|
], cls=DbConfigTunerService)
|
|
|
|
DbConfigTunerService.enable_pg_stat_statements(PG)
|
|
|
|
alter = next(s for s in seen if s.startswith('ALTER SYSTEM'))
|
|
assert alter == ("ALTER SYSTEM SET shared_preload_libraries = "
|
|
"'auto_explain, timescaledb, pg_stat_statements';")
|
|
assert tuner == [{'shared_preload_libraries': 'auto_explain, timescaledb, pg_stat_statements'}]
|
|
assert seen[-1] == 'CREATE EXTENSION IF NOT EXISTS pg_stat_statements;'
|
|
|
|
def test_already_preloaded_only_creates_the_extension(self, monkeypatch, tuner):
|
|
seen = _script(monkeypatch, [
|
|
('SHOW shared_preload_libraries', ok('pg_stat_statements\n')),
|
|
('CREATE EXTENSION', ok()),
|
|
], cls=DbConfigTunerService)
|
|
result = DbConfigTunerService.enable_pg_stat_statements(PG)
|
|
assert result['restarted'] is False and tuner == []
|
|
assert not any(s.startswith('ALTER SYSTEM') for s in seen)
|
|
|
|
def test_mysql_is_refused(self, tuner):
|
|
with pytest.raises(ValidationError):
|
|
DbConfigTunerService.enable_pg_stat_statements({'engine': 'mysql', 'container': 'db'})
|
|
|
|
|
|
def test_api(client, auth_headers, monkeypatch):
|
|
_script(monkeypatch, [('pg_stat_database', ok('100\t2\t1\t1\t0\t0\n'))])
|
|
ok_resp = client.get('/api/v1/databases/docker/pg/insights?type=postgresql',
|
|
headers={**auth_headers, 'X-DB-Password': 'x'})
|
|
assert ok_resp.status_code == 200 and ok_resp.get_json()['connections']['max'] == 100
|
|
bad = client.get('/api/v1/databases/docker/pg/insights?type=oracle', headers=auth_headers)
|
|
assert bad.status_code == 400
|