Files
ServerKit/backend/app/services/database_service.py
T
Juan DenisandClaude Opus 5 679082bf16 feat(databases): engine extensions, without the silent failure
The third deferred item. An extension installs INTO a running engine rather than
getting its own container, so it lives in its own namespace:
`backend/templates/extensions/<id>.yaml` with a top-level `extension:` block,
discovered by the same operator-wins two-directory scan the app templates use.
Dropping a YAML in makes it available with no code change, and a test pins that.

Not nested inside the engine template, because pgvector applies to any
PostgreSQL-speaking engine rather than to `postgresql.yaml` — nesting would force
duplication per host. Not a third `kind:` in the app catalog either: an
extension has no compose, port or volume and produces no Application, so every
consumer of `list_all_templates` would have had to learn to skip it.

The hard part is not the SQL. `CREATE EXTENSION vector` is valid and fails on
stock `postgres:16` with "could not open extension control file", because that
image does not ship the library. Two layers, and the statement is never sent
speculatively: an extension declares which images provide it, so a listing marks
an instance unsupported BEFORE anything is clicked; then at install
`pg_available_extensions` is queried first and a negative answer is a 409 with a
remedy, not an attempt. The live probe overrules the image name in both
directions — a private `registry.internal/ops/pg-with-vector` is allowed, and a
`pgvector/pgvector` tag that does not actually carry the library is still
refused.

ServerKit does not recreate a container behind the operator's back. The remedy
instead names an engine template that IS capable, found by matching the
extension's images against each engine template's compose — hence
`postgresql-pgvector.yaml`. TimescaleDB deliberately ships no bundled host and
says so rather than implying one exists.

Compatibility also needed correcting mid-build: matching on wire protocol alone
claimed CockroachDB could host pgvector. It answers on pgwire and cannot load a
single extension. Engines opt in with `engine.extensions: true`, which excludes
it without any Python naming it.

Two fixes fell out. `DatabaseService` had no psql twin of `docker_mysql_execute`
— `managed_db_user_service` carried a private copy with a comment saying as
much; there is now one implementation and it delegates. And that twin sets
`ON_ERROR_STOP=1`, without which psql reports SUCCESS for a statement the server
rejected.

57 tests, covering the zero-code-change property, compatibility, the image
problem in both probe directions, admin gating, and refusals that must not
execute anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:44:34 -04:00

1601 lines
61 KiB
Python

import subprocess
import os
import re
import secrets
import string
import json
from datetime import datetime
from app import paths
def _validate_identifier(name: str, max_length: int = 64) -> bool:
"""Validate database/user identifiers to prevent SQL injection."""
return bool(re.match(r'^[a-zA-Z0-9_$]{1,}$', name)) and len(name) <= max_length
class DatabaseService:
"""Service for managing MySQL/MariaDB and PostgreSQL databases.
NOTE (L6): Subprocess timeout values in this service (typically 30s) should be
reviewed periodically to ensure they are appropriate. Shorter timeouts reduce
the window for resource exhaustion attacks, but may break legitimate long-running
operations like large database backups or restores.
"""
BACKUP_DIR = paths.DB_BACKUP_DIR
# ==================== MYSQL/MARIADB ====================
@staticmethod
def mysql_is_installed():
"""Check if MySQL/MariaDB is installed."""
try:
result = subprocess.run(
['mysql', '--version'],
capture_output=True, text=True
)
return result.returncode == 0
except FileNotFoundError:
return False
@staticmethod
def mysql_is_running():
"""Check if MySQL/MariaDB is running."""
try:
result = subprocess.run(
['systemctl', 'is-active', 'mysql'],
capture_output=True, text=True
)
if result.returncode == 0:
return True
# Try mariadb service name
result = subprocess.run(
['systemctl', 'is-active', 'mariadb'],
capture_output=True, text=True
)
return result.returncode == 0
except Exception:
return False
@staticmethod
def mysql_execute(query, database=None, root_password=None):
"""Execute a MySQL query."""
try:
cmd = ['mysql', '-u', 'root']
if database:
cmd.extend(['-D', database])
cmd.extend(['-e', query])
# Use MYSQL_PWD env var to avoid passing password on CLI
env = None
if root_password:
env = os.environ.copy()
env['MYSQL_PWD'] = root_password
result = subprocess.run(
cmd, capture_output=True, text=True, env=env
)
return {
'success': result.returncode == 0,
'output': result.stdout,
'error': result.stderr if result.returncode != 0 else None
}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def _mysql_execute_parameterized(query, params, database=None, root_password=None):
"""Execute a MySQL query with parameterized values using mysql CLI.
Uses the mysql --execute flag with a prepared statement approach:
passes the query through stdin with proper escaping to avoid injection.
Args:
query: SQL query with %s placeholders
params: List of parameter values to substitute safely
database: Optional database name
root_password: Optional MySQL root password
"""
try:
# Build the parameterized query using mysql's built-in escaping
# by passing values through a SET/EXECUTE pattern via stdin
cmd = ['mysql', '-u', 'root', '--batch', '-N']
if database:
cmd.extend(['-D', database])
# Use MYSQL_PWD env var to avoid passing password on CLI
env = None
if root_password:
env = os.environ.copy()
env['MYSQL_PWD'] = root_password
# Build a safe query using user-defined variables and EXECUTE
# For simple single-param queries, we use a quoted literal approach
# MySQL's cli doesn't support true parameterized queries, so we use
# hex-encoding for string safety
safe_params = []
for p in params:
if p is None:
safe_params.append('NULL')
elif isinstance(p, (int, float)):
safe_params.append(str(p))
else:
# Hex-encode string values: 0x<hex> is safe from injection
hex_val = p.encode('utf-8').hex()
safe_params.append(f"0x{hex_val}")
# Replace %s placeholders with safe values
safe_query = query
for sp in safe_params:
safe_query = safe_query.replace('%s', sp, 1)
cmd.extend(['-e', safe_query])
result = subprocess.run(
cmd, capture_output=True, text=True, env=env
)
return {
'success': result.returncode == 0,
'output': result.stdout,
'error': result.stderr if result.returncode != 0 else None
}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def mysql_list_databases(root_password=None):
"""List all MySQL databases."""
result = DatabaseService.mysql_execute(
"SHOW DATABASES;",
root_password=root_password
)
if not result['success']:
return []
databases = []
system_dbs = ['information_schema', 'mysql', 'performance_schema', 'sys']
for line in result['output'].strip().split('\n')[1:]:
db_name = line.strip()
if db_name and db_name not in system_dbs:
# Get database size using parameterized query via MySQL CLI
# Pass db_name as a separate argument to avoid SQL injection
size_query = "SELECT SUM(data_length + index_length) as size FROM information_schema.tables WHERE table_schema = %s;"
size_result = DatabaseService._mysql_execute_parameterized(
size_query, [db_name], root_password=root_password
)
size = 0
if size_result['success']:
try:
size_line = size_result['output'].strip().split('\n')[0]
size = int(size_line) if size_line and size_line != 'NULL' else 0
except (IndexError, ValueError):
pass
databases.append({
'name': db_name,
'size': size,
'type': 'mysql'
})
return databases
@staticmethod
def mysql_create_database(name, charset='utf8mb4', collation='utf8mb4_unicode_ci', root_password=None):
"""Create a MySQL database."""
if not _validate_identifier(name):
return {'success': False, 'error': 'Invalid identifier: only alphanumeric characters and underscores allowed'}
if not _validate_identifier(charset):
return {'success': False, 'error': 'Invalid charset identifier'}
if not _validate_identifier(collation, max_length=128):
return {'success': False, 'error': 'Invalid collation identifier'}
query = f"CREATE DATABASE IF NOT EXISTS `{name}` CHARACTER SET {charset} COLLATE {collation};"
result = DatabaseService.mysql_execute(query, root_password=root_password)
return result
@staticmethod
def mysql_drop_database(name, root_password=None):
"""Drop a MySQL database."""
if not _validate_identifier(name):
return {'success': False, 'error': 'Invalid identifier: only alphanumeric characters and underscores allowed'}
query = f"DROP DATABASE IF EXISTS `{name}`;"
result = DatabaseService.mysql_execute(query, root_password=root_password)
return result
@staticmethod
def mysql_list_users(root_password=None):
"""List MySQL users."""
result = DatabaseService.mysql_execute(
"SELECT User, Host FROM mysql.user;",
root_password=root_password
)
if not result['success']:
return []
users = []
system_users = ['root', 'mysql.sys', 'mysql.session', 'mysql.infoschema', 'debian-sys-maint']
for line in result['output'].strip().split('\n')[1:]:
parts = line.strip().split('\t')
if len(parts) >= 2:
user, host = parts[0], parts[1]
if user not in system_users:
users.append({'user': user, 'host': host})
return users
@staticmethod
def mysql_create_user(username, password, host='localhost', root_password=None):
"""Create a MySQL user."""
if not _validate_identifier(username):
return {'success': False, 'error': 'Invalid identifier: only alphanumeric characters and underscores allowed'}
if not _validate_identifier(host):
return {'success': False, 'error': 'Invalid host identifier'}
# Use hex-encoded password with UNHEX + QUOTE to safely pass the password
# without manual string escaping. Username and host are validated above.
try:
cmd = ['mysql', '-u', 'root']
env = None
if root_password:
env = os.environ.copy()
env['MYSQL_PWD'] = root_password
# Hex-encode the password so it never appears as a raw string in SQL.
# UNHEX converts it back to bytes, CAST converts to string, QUOTE wraps
# it safely for use in a dynamic SQL statement.
hex_pw = password.encode('utf-8').hex()
safe_stmt = (
f"SET @pw = UNHEX('{hex_pw}');\n"
f"SET @pw = CAST(@pw AS CHAR);\n"
f"SET @sql = CONCAT('CREATE USER IF NOT EXISTS ''{username}''@''{host}'' IDENTIFIED BY ', QUOTE(@pw));\n"
f"PREPARE stmt FROM @sql;\n"
f"EXECUTE stmt;\n"
f"DEALLOCATE PREPARE stmt;\n"
)
result = subprocess.run(
cmd, capture_output=True, text=True, input=safe_stmt, env=env
)
return {
'success': result.returncode == 0,
'output': result.stdout,
'error': result.stderr if result.returncode != 0 else None
}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def mysql_drop_user(username, host='localhost', root_password=None):
"""Drop a MySQL user."""
if not _validate_identifier(username):
return {'success': False, 'error': 'Invalid identifier: only alphanumeric characters and underscores allowed'}
if not _validate_identifier(host):
return {'success': False, 'error': 'Invalid host identifier'}
query = f"DROP USER IF EXISTS '{username}'@'{host}';"
result = DatabaseService.mysql_execute(query, root_password=root_password)
return result
@staticmethod
def mysql_grant_privileges(username, database, privileges='ALL', host='localhost', root_password=None):
"""Grant privileges to a MySQL user."""
if not _validate_identifier(username):
return {'success': False, 'error': 'Invalid identifier: only alphanumeric characters and underscores allowed'}
if not _validate_identifier(database):
return {'success': False, 'error': 'Invalid identifier: only alphanumeric characters and underscores allowed'}
if not _validate_identifier(host):
return {'success': False, 'error': 'Invalid host identifier'}
query = f"GRANT {privileges} ON `{database}`.* TO '{username}'@'{host}'; FLUSH PRIVILEGES;"
result = DatabaseService.mysql_execute(query, root_password=root_password)
return result
@staticmethod
def mysql_revoke_privileges(username, database, privileges='ALL', host='localhost', root_password=None):
"""Revoke privileges from a MySQL user."""
if not _validate_identifier(username):
return {'success': False, 'error': 'Invalid identifier: only alphanumeric characters and underscores allowed'}
if not _validate_identifier(database):
return {'success': False, 'error': 'Invalid identifier: only alphanumeric characters and underscores allowed'}
if not _validate_identifier(host):
return {'success': False, 'error': 'Invalid host identifier'}
query = f"REVOKE {privileges} ON `{database}`.* FROM '{username}'@'{host}'; FLUSH PRIVILEGES;"
result = DatabaseService.mysql_execute(query, root_password=root_password)
return result
@staticmethod
def mysql_get_user_privileges(username, host='localhost', root_password=None):
"""Get privileges for a MySQL user."""
if not _validate_identifier(username):
return []
if not _validate_identifier(host):
return []
result = DatabaseService.mysql_execute(
f"SHOW GRANTS FOR '{username}'@'{host}';",
root_password=root_password
)
if not result['success']:
return []
privileges = []
for line in result['output'].strip().split('\n')[1:]:
privileges.append(line.strip())
return privileges
@staticmethod
def mysql_backup(database, output_path=None, root_password=None):
"""Backup a MySQL database."""
os.makedirs(DatabaseService.BACKUP_DIR, exist_ok=True)
if not output_path:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
output_path = os.path.join(
DatabaseService.BACKUP_DIR,
f"mysql_{database}_{timestamp}.sql.gz"
)
try:
cmd = ['mysqldump', '-u', 'root']
cmd.append(database)
# Use MYSQL_PWD env var to avoid passing password on CLI
env = None
if root_password:
env = os.environ.copy()
env['MYSQL_PWD'] = root_password
# Pipe through gzip
with open(output_path, 'wb') as f:
dump = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
gzip = subprocess.Popen(['gzip'], stdin=dump.stdout, stdout=f, stderr=subprocess.PIPE)
dump.stdout.close()
gzip.communicate()
if dump.wait() != 0 or gzip.returncode != 0:
return {'success': False, 'error': 'Backup failed'}
return {
'success': True,
'backup_path': output_path,
'size': os.path.getsize(output_path)
}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def mysql_restore(database, backup_path, root_password=None):
"""Restore a MySQL database from backup."""
if not os.path.exists(backup_path):
return {'success': False, 'error': 'Backup file not found'}
try:
cmd = ['mysql', '-u', 'root']
cmd.append(database)
# Use MYSQL_PWD env var to avoid passing password on CLI
env = None
if root_password:
env = os.environ.copy()
env['MYSQL_PWD'] = root_password
if backup_path.endswith('.gz'):
# Decompress and restore
gunzip = subprocess.Popen(['gunzip', '-c', backup_path], stdout=subprocess.PIPE)
restore = subprocess.Popen(cmd, stdin=gunzip.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
gunzip.stdout.close()
_, stderr = restore.communicate()
if restore.returncode != 0:
return {'success': False, 'error': stderr.decode()}
else:
with open(backup_path, 'r') as f:
result = subprocess.run(cmd, stdin=f, capture_output=True, text=True, env=env)
if result.returncode != 0:
return {'success': False, 'error': result.stderr}
return {'success': True, 'message': 'Database restored successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def mysql_get_tables(database, root_password=None):
"""Get tables in a MySQL database."""
result = DatabaseService.mysql_execute(
"SHOW TABLES;",
database=database,
root_password=root_password
)
if not result['success']:
return []
tables = []
for line in result['output'].strip().split('\n')[1:]:
table_name = line.strip()
if table_name:
# Get table info
info_result = DatabaseService.mysql_execute(
f"SELECT COUNT(*) as rows FROM `{table_name}`;",
database=database,
root_password=root_password
)
rows = 0
if info_result['success']:
try:
rows = int(info_result['output'].strip().split('\n')[1])
except (IndexError, ValueError):
pass
tables.append({
'name': table_name,
'rows': rows
})
return tables
# ==================== POSTGRESQL ====================
@staticmethod
def pg_is_installed():
"""Check if PostgreSQL is installed."""
try:
result = subprocess.run(
['psql', '--version'],
capture_output=True, text=True
)
return result.returncode == 0
except FileNotFoundError:
return False
@staticmethod
def pg_is_running():
"""Check if PostgreSQL is running."""
try:
result = subprocess.run(
['systemctl', 'is-active', 'postgresql'],
capture_output=True, text=True
)
return result.returncode == 0
except Exception:
return False
@staticmethod
def pg_execute(query, database='postgres', user='postgres'):
"""Execute a PostgreSQL query."""
try:
cmd = ['sudo', '-u', 'postgres', 'psql', '-d', database, '-c', query, '-t', '-A']
result = subprocess.run(cmd, capture_output=True, text=True)
return {
'success': result.returncode == 0,
'output': result.stdout,
'error': result.stderr if result.returncode != 0 else None
}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def pg_list_databases():
"""List all PostgreSQL databases."""
result = DatabaseService.pg_execute(
"SELECT datname FROM pg_database WHERE datistemplate = false;"
)
if not result['success']:
return []
databases = []
system_dbs = ['postgres']
for line in result['output'].strip().split('\n'):
db_name = line.strip()
if db_name and db_name not in system_dbs:
# Get database size
size_result = DatabaseService.pg_execute(
f"SELECT pg_database_size('{db_name}');"
)
size = 0
if size_result['success']:
try:
size = int(size_result['output'].strip())
except ValueError:
pass
databases.append({
'name': db_name,
'size': size,
'type': 'postgresql'
})
return databases
@staticmethod
def pg_create_database(name, owner=None, encoding='UTF8'):
"""Create a PostgreSQL database."""
query = f"CREATE DATABASE \"{name}\" ENCODING '{encoding}'"
if owner:
query += f" OWNER \"{owner}\""
query += ";"
result = DatabaseService.pg_execute(query)
return result
@staticmethod
def pg_drop_database(name):
"""Drop a PostgreSQL database."""
if not _validate_identifier(name):
return {'success': False, 'error': 'Invalid identifier: only alphanumeric characters and underscores allowed'}
# Terminate connections first using psql variable binding to prevent injection
try:
cmd = [
'sudo', '-u', 'postgres', 'psql', '-d', 'postgres',
'-v', f'dbname={name}',
'-c', "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = :'dbname';",
'-t', '-A'
]
subprocess.run(cmd, capture_output=True, text=True)
except Exception:
pass
# Name is validated above, safe to use in identifier position
result = DatabaseService.pg_execute(f'DROP DATABASE IF EXISTS "{name}";')
return result
@staticmethod
def pg_list_users():
"""List PostgreSQL users/roles."""
result = DatabaseService.pg_execute(
"SELECT rolname FROM pg_roles WHERE rolcanlogin = true;"
)
if not result['success']:
return []
users = []
system_users = ['postgres']
for line in result['output'].strip().split('\n'):
user = line.strip()
if user and user not in system_users:
users.append({'user': user, 'host': 'local'})
return users
@staticmethod
def pg_create_user(username, password):
"""Create a PostgreSQL user."""
result = DatabaseService.pg_execute(
f"CREATE USER \"{username}\" WITH PASSWORD '{password}';"
)
return result
@staticmethod
def pg_drop_user(username):
"""Drop a PostgreSQL user."""
result = DatabaseService.pg_execute(f'DROP USER IF EXISTS "{username}";')
return result
@staticmethod
def pg_grant_privileges(username, database, privileges='ALL'):
"""Grant privileges to a PostgreSQL user."""
result = DatabaseService.pg_execute(
f'GRANT {privileges} PRIVILEGES ON DATABASE "{database}" TO "{username}";'
)
return result
@staticmethod
def pg_revoke_privileges(username, database, privileges='ALL'):
"""Revoke privileges from a PostgreSQL user."""
result = DatabaseService.pg_execute(
f'REVOKE {privileges} PRIVILEGES ON DATABASE "{database}" FROM "{username}";'
)
return result
@staticmethod
def pg_backup(database, output_path=None):
"""Backup a PostgreSQL database."""
os.makedirs(DatabaseService.BACKUP_DIR, exist_ok=True)
if not output_path:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
output_path = os.path.join(
DatabaseService.BACKUP_DIR,
f"pg_{database}_{timestamp}.sql.gz"
)
try:
cmd = ['sudo', '-u', 'postgres', 'pg_dump', database]
with open(output_path, 'wb') as f:
dump = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
gzip = subprocess.Popen(['gzip'], stdin=dump.stdout, stdout=f, stderr=subprocess.PIPE)
dump.stdout.close()
gzip.communicate()
if dump.wait() != 0 or gzip.returncode != 0:
return {'success': False, 'error': 'Backup failed'}
return {
'success': True,
'backup_path': output_path,
'size': os.path.getsize(output_path)
}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def pg_restore(database, backup_path):
"""Restore a PostgreSQL database from backup."""
if not os.path.exists(backup_path):
return {'success': False, 'error': 'Backup file not found'}
try:
cmd = ['sudo', '-u', 'postgres', 'psql', database]
if backup_path.endswith('.gz'):
gunzip = subprocess.Popen(['gunzip', '-c', backup_path], stdout=subprocess.PIPE)
restore = subprocess.Popen(cmd, stdin=gunzip.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
gunzip.stdout.close()
_, stderr = restore.communicate()
if restore.returncode != 0:
return {'success': False, 'error': stderr.decode()}
else:
with open(backup_path, 'r') as f:
result = subprocess.run(cmd, stdin=f, capture_output=True, text=True)
if result.returncode != 0:
return {'success': False, 'error': result.stderr}
return {'success': True, 'message': 'Database restored successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def pg_get_tables(database):
"""Get tables in a PostgreSQL database."""
result = DatabaseService.pg_execute(
"SELECT tablename FROM pg_tables WHERE schemaname = 'public';",
database=database
)
if not result['success']:
return []
tables = []
for line in result['output'].strip().split('\n'):
table_name = line.strip()
if table_name:
# Get row count
count_result = DatabaseService.pg_execute(
f"SELECT COUNT(*) FROM \"{table_name}\";",
database=database
)
rows = 0
if count_result['success']:
try:
rows = int(count_result['output'].strip())
except ValueError:
pass
tables.append({
'name': table_name,
'rows': rows
})
return tables
# ==================== QUERY EXECUTION ====================
# Allowed readonly commands for security
READONLY_COMMANDS = {'SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN'}
@staticmethod
def _is_readonly_query(query):
"""Check if a query is readonly (SELECT, SHOW, DESCRIBE, EXPLAIN)."""
# Remove leading whitespace and get first word
normalized = query.strip().upper()
first_word = normalized.split()[0] if normalized.split() else ''
return first_word in DatabaseService.READONLY_COMMANDS
@staticmethod
def mysql_execute_query(database, query, readonly=True, root_password=None, timeout=30, max_rows=1000):
"""Execute a MySQL query and return structured results.
Args:
database: Database name to query
query: SQL query to execute
readonly: If True, only allow SELECT/SHOW/DESCRIBE/EXPLAIN
root_password: MySQL root password
timeout: Query timeout in seconds
max_rows: Maximum rows to return
Returns:
dict with columns, rows, row_count, and execution info
"""
import json
import time
# Security check for readonly mode
if readonly and not DatabaseService._is_readonly_query(query):
return {
'success': False,
'error': 'Only SELECT, SHOW, DESCRIBE, and EXPLAIN queries are allowed in readonly mode'
}
try:
start_time = time.time()
# Build mysql command with JSON output format
cmd = ['mysql', '-u', 'root']
# Use MYSQL_PWD env var to avoid passing password on CLI
env = None
if root_password:
env = os.environ.copy()
env['MYSQL_PWD'] = root_password
cmd.extend([
'-D', database,
'-e', query,
'--batch', # Tab-separated output
'-N' if query.strip().upper().startswith(('SHOW', 'DESCRIBE', 'DESC')) else ''
])
# Remove empty strings from cmd
cmd = [c for c in cmd if c]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
env=env
)
execution_time = time.time() - start_time
if result.returncode != 0:
return {
'success': False,
'error': result.stderr.strip() if result.stderr else 'Query execution failed'
}
# Parse the output
lines = result.stdout.strip().split('\n') if result.stdout.strip() else []
if not lines:
return {
'success': True,
'columns': [],
'rows': [],
'row_count': 0,
'execution_time': execution_time,
'truncated': False
}
# First line is headers (unless -N was used)
if query.strip().upper().startswith(('SHOW', 'DESCRIBE', 'DESC')):
# For SHOW/DESCRIBE, we need to add generic column names
columns = [f'Column_{i}' for i in range(len(lines[0].split('\t')))] if lines else []
data_lines = lines
else:
columns = lines[0].split('\t') if lines else []
data_lines = lines[1:]
# Parse rows
rows = []
for i, line in enumerate(data_lines):
if i >= max_rows:
break
values = line.split('\t')
# Convert NULL strings to None
values = [None if v == 'NULL' else v for v in values]
rows.append(values)
return {
'success': True,
'columns': columns,
'rows': rows,
'row_count': len(rows),
'total_rows': len(data_lines),
'execution_time': round(execution_time, 4),
'truncated': len(data_lines) > max_rows
}
except subprocess.TimeoutExpired:
return {
'success': False,
'error': f'Query timed out after {timeout} seconds'
}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def pg_execute_query(database, query, readonly=True, timeout=30, max_rows=1000):
"""Execute a PostgreSQL query and return structured results.
Args:
database: Database name to query
query: SQL query to execute
readonly: If True, only allow SELECT/SHOW/DESCRIBE/EXPLAIN
timeout: Query timeout in seconds
max_rows: Maximum rows to return
Returns:
dict with columns, rows, row_count, and execution info
"""
import time
# Security check for readonly mode
if readonly and not DatabaseService._is_readonly_query(query):
return {
'success': False,
'error': 'Only SELECT, SHOW, DESCRIBE, and EXPLAIN queries are allowed in readonly mode'
}
try:
start_time = time.time()
# For PostgreSQL, we use psql with specific formatting
# -F sets field separator, -A for unaligned output
cmd = [
'sudo', '-u', 'postgres', 'psql',
'-d', database,
'-c', query,
'-F', '\t', # Tab separator
'-A', # Unaligned output
'--pset', 'footer=off' # No row count footer
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout
)
execution_time = time.time() - start_time
if result.returncode != 0:
error_msg = result.stderr.strip() if result.stderr else 'Query execution failed'
return {
'success': False,
'error': error_msg
}
# Parse the output
lines = result.stdout.strip().split('\n') if result.stdout.strip() else []
if not lines:
return {
'success': True,
'columns': [],
'rows': [],
'row_count': 0,
'execution_time': execution_time,
'truncated': False
}
# First line is headers
columns = lines[0].split('\t') if lines else []
data_lines = lines[1:] if len(lines) > 1 else []
# Parse rows
rows = []
for i, line in enumerate(data_lines):
if i >= max_rows:
break
if line.strip(): # Skip empty lines
values = line.split('\t')
# Convert empty strings and special values
values = [None if v == '' else v for v in values]
rows.append(values)
return {
'success': True,
'columns': columns,
'rows': rows,
'row_count': len(rows),
'total_rows': len(data_lines),
'execution_time': round(execution_time, 4),
'truncated': len(data_lines) > max_rows
}
except subprocess.TimeoutExpired:
return {
'success': False,
'error': f'Query timed out after {timeout} seconds'
}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def sqlite_execute_query(db_path, query, readonly=True, timeout=30, max_rows=1000):
"""Execute a SQLite query and return structured results.
Args:
db_path: Path to SQLite database file
query: SQL query to execute
readonly: If True, only allow SELECT/SHOW/DESCRIBE/EXPLAIN
timeout: Query timeout in seconds
max_rows: Maximum rows to return
Returns:
dict with columns, rows, row_count, and execution info
"""
import sqlite3
import time
# Security check for readonly mode
if readonly and not DatabaseService._is_readonly_query(query):
return {
'success': False,
'error': 'Only SELECT queries are allowed in readonly mode'
}
# Validate path exists
if not os.path.exists(db_path):
return {'success': False, 'error': f'Database file not found: {db_path}'}
try:
start_time = time.time()
# Open in readonly mode if readonly is True
uri = f'file:{db_path}?mode=ro' if readonly else db_path
conn = sqlite3.connect(uri, uri=readonly, timeout=timeout)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(query)
# Get column names
columns = [description[0] for description in cursor.description] if cursor.description else []
# Fetch rows with limit
rows = []
total_fetched = 0
for row in cursor:
total_fetched += 1
if len(rows) < max_rows:
rows.append(list(row))
execution_time = time.time() - start_time
conn.close()
return {
'success': True,
'columns': columns,
'rows': rows,
'row_count': len(rows),
'total_rows': total_fetched,
'execution_time': round(execution_time, 4),
'truncated': total_fetched > max_rows
}
except sqlite3.OperationalError as e:
return {'success': False, 'error': str(e)}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def mysql_get_table_structure(database, table, root_password=None):
"""Get the structure/schema of a MySQL table."""
query = f"DESCRIBE `{table}`;"
result = DatabaseService.mysql_execute(query, database=database, root_password=root_password)
if not result['success']:
return result
columns = []
for line in result['output'].strip().split('\n')[1:]:
parts = line.split('\t')
if len(parts) >= 6:
columns.append({
'name': parts[0],
'type': parts[1],
'nullable': parts[2] == 'YES',
'key': parts[3],
'default': parts[4] if parts[4] != 'NULL' else None,
'extra': parts[5]
})
return {'success': True, 'columns': columns, 'table': table}
@staticmethod
def pg_get_table_structure(database, table):
"""Get the structure/schema of a PostgreSQL table."""
query = f"""
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = '{table}' AND table_schema = 'public'
ORDER BY ordinal_position;
"""
result = DatabaseService.pg_execute(query, database=database)
if not result['success']:
return result
columns = []
for line in result['output'].strip().split('\n'):
parts = line.split('|')
if len(parts) >= 4:
columns.append({
'name': parts[0].strip(),
'type': parts[1].strip(),
'nullable': parts[2].strip() == 'YES',
'default': parts[3].strip() if parts[3].strip() else None
})
return {'success': True, 'columns': columns, 'table': table}
@staticmethod
def sqlite_get_table_structure(db_path, table):
"""Get the structure/schema of a SQLite table."""
import sqlite3
if not os.path.exists(db_path):
return {'success': False, 'error': f'Database file not found: {db_path}'}
try:
conn = sqlite3.connect(f'file:{db_path}?mode=ro', uri=True)
cursor = conn.cursor()
cursor.execute(f"PRAGMA table_info(`{table}`);")
rows = cursor.fetchall()
conn.close()
columns = []
for row in rows:
columns.append({
'name': row[1],
'type': row[2],
'nullable': row[3] == 0,
'default': row[4],
'primary_key': row[5] == 1
})
return {'success': True, 'columns': columns, 'table': table}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def sqlite_list_databases(search_paths=None):
"""Find SQLite database files in common locations."""
if search_paths is None:
search_paths = ['/var/www', '/home', '/opt']
databases = []
for base_path in search_paths:
if not os.path.exists(base_path):
continue
try:
for root, dirs, files in os.walk(base_path):
# Skip hidden directories and common non-app directories
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ['node_modules', 'vendor', '__pycache__']]
for file in files:
if file.endswith(('.db', '.sqlite', '.sqlite3')):
filepath = os.path.join(root, file)
try:
size = os.path.getsize(filepath)
databases.append({
'name': file,
'path': filepath,
'size': size,
'type': 'sqlite'
})
except OSError:
continue
except PermissionError:
continue
return databases
@staticmethod
def sqlite_get_tables(db_path):
"""Get tables in a SQLite database."""
import sqlite3
if not os.path.exists(db_path):
return []
try:
conn = sqlite3.connect(f'file:{db_path}?mode=ro', uri=True)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';")
tables = []
for row in cursor.fetchall():
table_name = row[0]
# Get row count
cursor.execute(f"SELECT COUNT(*) FROM `{table_name}`;")
count = cursor.fetchone()[0]
tables.append({'name': table_name, 'rows': count})
conn.close()
return tables
except Exception:
return []
# ==================== UTILITY ====================
@staticmethod
def get_status():
"""Get database server status."""
return {
'mysql': {
'installed': DatabaseService.mysql_is_installed(),
'running': DatabaseService.mysql_is_running()
},
'postgresql': {
'installed': DatabaseService.pg_is_installed(),
'running': DatabaseService.pg_is_running()
}
}
@staticmethod
def list_backups(db_type=None):
"""List all database backups."""
if not os.path.exists(DatabaseService.BACKUP_DIR):
return []
backups = []
for filename in os.listdir(DatabaseService.BACKUP_DIR):
if filename.endswith('.sql') or filename.endswith('.sql.gz'):
filepath = os.path.join(DatabaseService.BACKUP_DIR, filename)
backup_type = 'mysql' if filename.startswith('mysql_') else 'postgresql'
if db_type and backup_type != db_type:
continue
# Parse database name from filename
parts = filename.replace('.sql.gz', '').replace('.sql', '').split('_')
db_name = '_'.join(parts[1:-2]) if len(parts) > 3 else parts[1] if len(parts) > 1 else 'unknown'
backups.append({
'filename': filename,
'path': filepath,
'type': backup_type,
'database': db_name,
'size': os.path.getsize(filepath),
'created_at': datetime.fromtimestamp(os.path.getctime(filepath)).isoformat()
})
return sorted(backups, key=lambda x: x['created_at'], reverse=True)
@staticmethod
def delete_backup(filename):
"""Delete a backup file."""
filepath = os.path.join(DatabaseService.BACKUP_DIR, filename)
if os.path.exists(filepath):
os.remove(filepath)
return {'success': True}
return {'success': False, 'error': 'Backup not found'}
@staticmethod
def generate_password(length=16):
"""Generate a secure random password."""
alphabet = string.ascii_letters + string.digits + '!@#$%^&*'
return ''.join(secrets.choice(alphabet) for _ in range(length))
# ==================== DOCKER CONTAINER DATABASES ====================
@staticmethod
def list_docker_mysql_containers():
"""Find all Docker containers running MySQL/MariaDB."""
try:
# Get all running containers
result = subprocess.run(
['docker', 'ps', '--format', '{{json .}}'],
capture_output=True, text=True
)
if result.returncode != 0:
return []
containers = []
mysql_images = ['mysql', 'mariadb', 'percona']
for line in result.stdout.strip().split('\n'):
if not line:
continue
container = json.loads(line)
image = container.get('Image', '').lower()
# Check if it's a MySQL-like container
if any(img in image for img in mysql_images):
containers.append({
'id': container.get('ID'),
'name': container.get('Names'),
'image': container.get('Image'),
'status': container.get('Status'),
'ports': container.get('Ports'),
'type': 'mysql'
})
return containers
except Exception:
return []
# Which CLI a DB container actually ships. MariaDB 11 dropped the `mysql`
# symlink in favour of `mariadb`, so a hardcoded 'mysql' fails outright with
# "executable file not found in $PATH" against any modern MariaDB image —
# including the mariadb:11.4 our own engine template installs. Resolved once
# per container and remembered; the answer cannot change while it runs.
_docker_client_cache = {}
@staticmethod
def _docker_db_client(container_name):
"""Name of the mysql-compatible client available inside a container."""
cached = DatabaseService._docker_client_cache.get(container_name)
if cached:
return cached
for client in ('mariadb', 'mysql'):
try:
probe = subprocess.run(
['docker', 'exec', container_name, client, '--version'],
capture_output=True, text=True, timeout=10,
)
if probe.returncode == 0:
DatabaseService._docker_client_cache[container_name] = client
return client
except Exception:
continue
# Nothing answered — keep the historic default so the caller's error
# message stays the familiar one.
return 'mysql'
@staticmethod
def docker_mysql_execute(container_name, query, database=None, user='root', password=None):
"""Execute a MySQL query inside a Docker container."""
try:
cmd = ['docker', 'exec']
# Use MYSQL_PWD env var to avoid passing password on CLI
if password:
cmd.extend(['-e', f'MYSQL_PWD={password}'])
cmd.extend([container_name, DatabaseService._docker_db_client(container_name), '-u', user])
if database:
cmd.extend(['-D', database])
cmd.extend(['-e', query])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
return {
'success': result.returncode == 0,
'output': result.stdout,
'error': result.stderr if result.returncode != 0 else None
}
except subprocess.TimeoutExpired:
return {'success': False, 'error': 'Query timed out'}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def docker_pg_execute(container_name, query, database='postgres', user='postgres',
password=None, timeout=30):
"""Execute a PostgreSQL statement inside a Docker container.
The psql twin of :meth:`docker_mysql_execute`, so anything that has to
talk to a containerised engine has one place to do it.
``ON_ERROR_STOP=1`` is not optional: without it psql happily reports
success for a statement the server rejected, which turns a real failure
(``could not open extension control file``) into a silent no-op.
Output is ``-t -A``: no header, no padding, ``|``-separated columns.
"""
try:
cmd = ['docker', 'exec']
# PGPASSWORD via the exec environment, never on the command line.
if password:
cmd.extend(['-e', f'PGPASSWORD={password}'])
cmd.extend([container_name, 'psql', '-U', user,
'-d', database or 'postgres',
'-v', 'ON_ERROR_STOP=1',
'-c', query, '-t', '-A'])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return {
'success': result.returncode == 0,
'output': result.stdout,
'error': result.stderr if result.returncode != 0 else None,
}
except subprocess.TimeoutExpired:
return {'success': False, 'error': 'Query timed out'}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def docker_mysql_list_databases(container_name, user='root', password=None):
"""List databases in a Docker MySQL container."""
result = DatabaseService.docker_mysql_execute(
container_name,
"SHOW DATABASES;",
user=user,
password=password
)
if not result['success']:
return []
databases = []
system_dbs = ['information_schema', 'mysql', 'performance_schema', 'sys']
for line in result['output'].strip().split('\n')[1:]:
db_name = line.strip()
if db_name and db_name not in system_dbs:
databases.append({
'name': db_name,
'type': 'docker_mysql',
'container': container_name
})
return databases
@staticmethod
def docker_mysql_get_tables(container_name, database, user='root', password=None):
"""List tables in a Docker MySQL database.
Returns ``{'connected': bool, 'tables': [...], 'error': str|None}`` so
callers can tell a genuinely empty database apart from a container/auth
failure. Both used to collapse to ``[]`` and the explorer rendered
"Empty" either way, silently hiding a broken connection.
"""
result = DatabaseService.docker_mysql_execute(
container_name,
"SHOW TABLES;",
database=database,
user=user,
password=password
)
if not result['success']:
return {
'connected': False,
'tables': [],
'error': result.get('error') or 'Could not connect to the database container',
}
tables = []
for line in result['output'].strip().split('\n')[1:]:
table_name = line.strip()
if table_name:
# Get row count
count_result = DatabaseService.docker_mysql_execute(
container_name,
f"SELECT COUNT(*) FROM `{table_name}`;",
database=database,
user=user,
password=password
)
rows = 0
if count_result['success']:
try:
rows = int(count_result['output'].strip().split('\n')[1])
except (IndexError, ValueError):
pass
tables.append({
'name': table_name,
'rows': rows
})
return {'connected': True, 'tables': tables, 'error': None}
@staticmethod
def docker_mysql_execute_query(container_name, database, query, user='root', password=None,
readonly=True, timeout=30, max_rows=1000):
"""Execute a query against a Docker MySQL container and return structured results."""
import time
# Security check for readonly mode
if readonly and not DatabaseService._is_readonly_query(query):
return {
'success': False,
'error': 'Only SELECT, SHOW, DESCRIBE, and EXPLAIN queries are allowed in readonly mode'
}
try:
start_time = time.time()
cmd = ['docker', 'exec']
# Use MYSQL_PWD env var to avoid passing password on CLI
if password:
cmd.extend(['-e', f'MYSQL_PWD={password}'])
cmd.extend([container_name, DatabaseService._docker_db_client(container_name), '-u', user])
cmd.extend([
'-D', database,
'-e', query,
'--batch'
])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
execution_time = time.time() - start_time
if result.returncode != 0:
return {
'success': False,
'error': result.stderr.strip() if result.stderr else 'Query execution failed'
}
# Parse the output
lines = result.stdout.strip().split('\n') if result.stdout.strip() else []
if not lines:
return {
'success': True,
'columns': [],
'rows': [],
'row_count': 0,
'execution_time': execution_time,
'truncated': False
}
# First line is headers
columns = lines[0].split('\t') if lines else []
data_lines = lines[1:] if len(lines) > 1 else []
# Parse rows
rows = []
for i, line in enumerate(data_lines):
if i >= max_rows:
break
values = line.split('\t')
values = [None if v == 'NULL' else v for v in values]
rows.append(values)
return {
'success': True,
'columns': columns,
'rows': rows,
'row_count': len(rows),
'total_rows': len(data_lines),
'execution_time': round(execution_time, 4),
'truncated': len(data_lines) > max_rows
}
except subprocess.TimeoutExpired:
return {
'success': False,
'error': f'Query timed out after {timeout} seconds'
}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def _resolve_app_container(app_path, service_name, fallback=None, env_vars=None):
"""Resolve the real running container name for a compose service.
docker-compose v2 names containers ``<project>-<service>-N`` and our
templates leave ``container_name`` as an unexpanded ``${APP_NAME}-db``
in the on-disk compose, so neither the literal value nor a
``<app>_<service>`` guess reliably matches the live container — which is
why ``docker exec`` silently failed and tables showed as empty. Ask
``docker compose ps`` what the container is actually called and match on
the service; fall back to expanding ``${VARS}`` from .env, then to the
raw fallback.
"""
# 1. Authoritative: ask docker what the container is actually called.
if app_path:
try:
from app.services.docker_service import DockerService
for c in DockerService.compose_ps(app_path):
svc = c.get('Service') or c.get('service')
if svc == service_name:
name = c.get('Name') or c.get('name') or c.get('ID') or c.get('id')
if name:
return name
except Exception:
pass
# 2. Best-effort: expand ${VARS} in the declared/guessed name from .env.
if fallback and env_vars and '${' in fallback:
fallback = re.sub(r'\$\{([^}]+)\}',
lambda m: env_vars.get(m.group(1), m.group(0)), fallback)
return fallback
@staticmethod
def get_app_database_info(app_name, app_path):
"""Get database info for a Docker app by reading its compose file."""
compose_path = os.path.join(app_path, 'docker-compose.yml') if app_path else None
env_path = os.path.join(app_path, '.env') if app_path else None
if not compose_path or not os.path.exists(compose_path):
return None
try:
import yaml
with open(compose_path, 'r') as f:
compose = yaml.safe_load(f)
# Read .env file for passwords
env_vars = {}
if env_path and os.path.exists(env_path):
with open(env_path, 'r') as f:
for line in f:
if '=' in line and not line.strip().startswith('#'):
key, value = line.strip().split('=', 1)
env_vars[key] = value
db_info = []
services = compose.get('services', {})
for service_name, service_config in services.items():
image = service_config.get('image', '').lower()
# Check for MySQL containers
if any(db in image for db in ['mysql', 'mariadb', 'percona']):
container_name = service_config.get('container_name', f'{app_name}_{service_name}')
container_name = DatabaseService._resolve_app_container(app_path, service_name, container_name, env_vars)
environment = service_config.get('environment', [])
# Extract credentials from environment
root_password = None
db_user = None
db_password = None
db_name = None
if isinstance(environment, list):
for env in environment:
if isinstance(env, str) and '=' in env:
key, value = env.split('=', 1)
elif isinstance(env, str):
key = env
value = env_vars.get(env.replace('${', '').replace('}', ''), '')
else:
continue
# Resolve ${VAR} syntax
if value.startswith('${') and value.endswith('}'):
var_name = value[2:-1]
value = env_vars.get(var_name, value)
if 'ROOT_PASSWORD' in key:
root_password = value
elif 'MYSQL_PASSWORD' in key and 'ROOT' not in key:
db_password = value
elif 'MYSQL_USER' in key:
db_user = value
elif 'MYSQL_DATABASE' in key:
db_name = value
elif isinstance(environment, dict):
for key, value in environment.items():
if isinstance(value, str) and value.startswith('${') and value.endswith('}'):
var_name = value[2:-1]
value = env_vars.get(var_name, value)
if 'ROOT_PASSWORD' in key:
root_password = value
elif 'MYSQL_PASSWORD' in key and 'ROOT' not in key:
db_password = value
elif 'MYSQL_USER' in key:
db_user = value
elif 'MYSQL_DATABASE' in key:
db_name = value
db_info.append({
'type': 'mysql',
'container': container_name,
'service': service_name,
'image': image,
'database': db_name,
'user': db_user or 'root',
'password': db_password or root_password,
'root_password': root_password
})
# Check for PostgreSQL containers
elif 'postgres' in image:
container_name = service_config.get('container_name', f'{app_name}_{service_name}')
container_name = DatabaseService._resolve_app_container(app_path, service_name, container_name, env_vars)
environment = service_config.get('environment', [])
pg_user = 'postgres'
pg_password = None
pg_db = None
if isinstance(environment, list):
for env in environment:
if isinstance(env, str) and '=' in env:
key, value = env.split('=', 1)
if value.startswith('${') and value.endswith('}'):
var_name = value[2:-1]
value = env_vars.get(var_name, value)
if 'POSTGRES_PASSWORD' in key:
pg_password = value
elif 'POSTGRES_USER' in key:
pg_user = value
elif 'POSTGRES_DB' in key:
pg_db = value
db_info.append({
'type': 'postgresql',
'container': container_name,
'service': service_name,
'image': image,
'database': pg_db,
'user': pg_user,
'password': pg_password
})
return db_info if db_info else None
except Exception as e:
return None