Files
ServerKit/backend/app/services/db_exec.py
T
Juan DenisandClaude Opus 5 b768fef728 refactor(db): one SQL-exec dispatcher, and adopt the doors already built
Plan 75 §G5 + §G6.

§G5 — app/services/db_exec.py. engine × host dispatch (mysql/postgres
crossed with native/docker) was written three times. Only
managed_db_user_service delegated fully to DatabaseService's executors;
the other two grew private copies that had already drifted —
db_process_service's docker-postgres branch built its own psql argv and
never set ON_ERROR_STOP=1, so psql exited 0 for a statement the server
had rejected and the caller was told it worked.

db_exec owns the dispatch and nothing else; the four executors stay in
DatabaseService. The three _exec_sql seams remain (tests stub them) and
now delegate. An unrecognised engine is reported, never quietly routed to
mysql, where it would surface as a syntax error blamed on the caller.

docker_mysql_execute/docker_pg_execute gain machine_readable, which is
the single flag db_config_tuner_service was keeping a whole private copy
of the dispatcher to add. The MYSQL_PWD env block, pasted six times in
database_service, becomes _mysql_env(): it carries a real rule — the
password goes in the environment, never on the argv — and a rule in six
copies only holds in the copies someone remembered.

§G6 — adopting doors Round 1 built:

- config_store's write is now atomic (tmp + os.replace). The §F4
  consolidation deliberately kept the copies' truncating write and noted
  atomicity would land here once; this is that once. A write that dies
  mid-dump leaves a truncated file, and every load path treats a corrupt
  config as "use the default" — a config silently reset.
- uptime_service._save_history was `except Exception: pass` over that
  truncating write, so a full disk destroyed the history and reported
  nothing while the panel kept rendering a figure from a file that had
  stopped updating. It now returns and logs the failure.
- unit_is_active(): `systemctl is-active` was re-inlined in four services
  and every copy collapsed "could not ask" into "not running". The shared
  probe is tri-state; None is not False.
- slug: preview_service and saved_view_service re-implemented slugify's
  regex, and saved_view re-implemented unique_slug's collision loop too.
  unique_slug gains start= so saved_view keeps generating the handles it
  always has.

Checked rather than assumed: the plan lists file_integrity_service as
silently losing data on a full disk. It does not — it already used tmp +
os.replace and does not swallow the error. It is the pattern followed
here, not a victim; only uptime_service had the silent loss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 11:26:40 -04:00

95 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""One SQL-exec dispatcher (plan 75 §G5).
`engine × host` dispatch — mysql/postgres crossed with native/docker — was
written three times: `db_config_tuner_service`, `db_process_service`, and
`managed_db_user_service`. Only the last delegated fully to `DatabaseService`'s
four executors; the other two grew private copies that had already drifted (one
never learned about `ON_ERROR_STOP=1`, so psql reported success for statements
the server rejected).
This module is the dispatch, and nothing else. The four executors stay in
`DatabaseService` — this does not become a second place where SQL is run, only
the single place where "which executor" is decided.
Every executor returns the same shape, and so does this::
{'success': bool, 'output': str, 'error': str|None}
A target is a plain dict so callers can build one from a `ManagedDatabase` row,
a container name, or a tuner probe without a common base class:
{'engine': 'mysql'|'postgresql', 'container': str|None,
'user': str|None, 'password': str|None, 'database': str|None}
`container` is what decides native-vs-docker. `None`/absent means native.
"""
from typing import Optional
from app.services.database_service import DatabaseService
# The names the rest of the codebase spells these engines with. Kept here so an
# unknown engine is *reported*, never silently routed to mysql (§A).
MYSQL_ALIASES = ('mysql', 'mariadb')
POSTGRES_ALIASES = ('postgresql', 'postgres', 'pgsql')
def target(engine: str, *, container: Optional[str] = None,
user: Optional[str] = None, password: Optional[str] = None,
database: Optional[str] = None) -> dict:
"""Build a target dict. Keyword-only so a call site reads at a glance."""
return {'engine': engine, 'container': container, 'user': user,
'password': password, 'database': database}
def target_from_managed(managed, *, password: Optional[str] = None) -> dict:
"""A target for a ``ManagedDatabase`` row.
The admin secret is passed in rather than decrypted here: decryption needs
app context, and this module deliberately does not reach for it.
"""
return target(
managed.engine,
container=managed.container_ref if managed.host_kind == 'docker' else None,
user=managed.admin_username or None,
password=password,
)
def exec_sql(tgt: dict, sql: str, *, machine_readable: bool = False,
timeout: int = 30) -> dict:
"""Run *sql* against *tgt*'s engine, native or containerised.
``machine_readable`` asks the client for parse-friendly output (no headers,
tab-separated). Pass it when the caller splits the output; leave it off
when the output is shown to a person.
"""
engine = (tgt.get('engine') or '').strip().lower()
container = tgt.get('container')
user = tgt.get('user')
password = tgt.get('password')
database = tgt.get('database')
if engine in MYSQL_ALIASES:
if container:
return DatabaseService.docker_mysql_execute(
container, sql, database=database, user=user or 'root',
password=password, machine_readable=machine_readable,
timeout=timeout)
return DatabaseService.mysql_execute(
sql, database=database, root_password=password)
if engine in POSTGRES_ALIASES:
if container:
return DatabaseService.docker_pg_execute(
container, sql, database=database or 'postgres',
user=user or 'postgres', password=password,
timeout=timeout, machine_readable=machine_readable)
return DatabaseService.pg_execute(
sql, database=database or 'postgres', user=user or 'postgres')
# Not a default. An unrecognised engine that quietly ran as mysql would
# produce a syntax error blamed on the caller's SQL.
return {'success': False, 'output': '',
'error': f"unsupported engine: {tgt.get('engine')!r}"}