# Bucket: PER-APP. The per-app docker DB read (/docker/app/) # gates on can_access_app; the bare-container docker routes have no app linkage # and are system-level admin-only, matching their kill/drop siblings. # The host-level raw routes (engine lists, tables/structure, users/privileges, # backups list, and the raw query consoles) likewise have no app linkage and are # admin-only (Decision 7) — per-app managed-database flows go through the # /docker/app/ and /managed surfaces instead. import logging from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required, get_jwt_identity from app.models import User, Application from app.services.database_service import DatabaseService from app.services.db_process_service import DbProcessService from app.services.managed_database_service import ManagedDatabaseService from app.middleware.rbac import admin_required, get_current_user, require_admin_user from app.services.resource_grant_service import ResourceGrantService from app.exceptions import field_required, not_found, permission_denied from app.api._request import json_object logger = logging.getLogger(__name__) databases_bp = Blueprint('databases', __name__) def _persist_provisioned(engine, name, result, data): """Track a just-provisioned database as a managed resource. Best-effort and additive — a persistence failure never fails the provisioning that succeeded.""" try: from app.services.workspace_service import WorkspaceService user = get_current_user() ws_id = WorkspaceService.resolve_workspace_id( user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id')) ManagedDatabaseService.record_provisioned( engine, name, admin_username=result.get('user'), admin_secret=result.get('password'), owner_application_id=(data or {}).get('application_id'), workspace_id=ws_id, ) except Exception as exc: # pragma: no cover - defensive logger.warning('Failed to track provisioned database %s/%s: %s', engine, name, exc) # ==================== STATUS ==================== @databases_bp.route('/status', methods=['GET']) @jwt_required() def get_status(): """Get database servers status.""" status = DatabaseService.get_status() return jsonify(status), 200 # ==================== MYSQL DATABASES ==================== @databases_bp.route('/mysql', methods=['GET']) @admin_required def list_mysql_databases(): """List MySQL databases. For security, root_password should be passed via X-DB-Password header, not query params. """ # Read root_password from header for security (not exposed in URL/logs) root_password = request.headers.get('X-DB-Password') databases = DatabaseService.mysql_list_databases(root_password) return jsonify({'databases': databases}), 200 @databases_bp.route('/mysql', methods=['POST']) @admin_required def create_mysql_database(): """Create a MySQL database.""" data = json_object() if not data or 'name' not in data: raise field_required('name') result = DatabaseService.mysql_create_database( data['name'], data.get('charset', 'utf8mb4'), data.get('collation', 'utf8mb4_unicode_ci'), data.get('root_password') ) if result['success']: # Optionally create user with same name if data.get('create_user'): password = data.get('user_password') or DatabaseService.generate_password() DatabaseService.mysql_create_user( data['name'], password, data.get('host', 'localhost'), data.get('root_password') ) DatabaseService.mysql_grant_privileges( data['name'], data['name'], 'ALL', data.get('host', 'localhost'), data.get('root_password') ) result['user'] = data['name'] result['password'] = password _persist_provisioned('mysql', data['name'], result, data) return jsonify(result), 201 if result['success'] else 400 @databases_bp.route('/mysql/', methods=['DELETE']) @admin_required def drop_mysql_database(name): """Drop a MySQL database.""" data = json_object() result = DatabaseService.mysql_drop_database(name, data.get('root_password')) return jsonify(result), 200 if result['success'] else 400 @databases_bp.route('/mysql//tables', methods=['GET']) @admin_required def get_mysql_tables(name): """Get tables in a MySQL database. For security, root_password should be passed via X-DB-Password header. """ root_password = request.headers.get('X-DB-Password') tables = DatabaseService.mysql_get_tables(name, root_password) return jsonify({'tables': tables}), 200 @databases_bp.route('/mysql//backup', methods=['POST']) @admin_required def backup_mysql_database(name): """Backup a MySQL database.""" data = json_object() result = DatabaseService.mysql_backup(name, root_password=data.get('root_password')) return jsonify(result), 200 if result['success'] else 400 @databases_bp.route('/mysql//restore', methods=['POST']) @admin_required def restore_mysql_database(name): """Restore a MySQL database from backup.""" data = json_object() if not data or 'backup_path' not in data: raise field_required('backup_path') result = DatabaseService.mysql_restore( name, data['backup_path'], data.get('root_password') ) return jsonify(result), 200 if result['success'] else 400 # ==================== MYSQL USERS ==================== @databases_bp.route('/mysql/users', methods=['GET']) @admin_required def list_mysql_users(): """List MySQL users. For security, root_password should be passed via X-DB-Password header. """ root_password = request.headers.get('X-DB-Password') users = DatabaseService.mysql_list_users(root_password) return jsonify({'users': users}), 200 @databases_bp.route('/mysql/users', methods=['POST']) @admin_required def create_mysql_user(): """Create a MySQL user.""" data = json_object() if not data or 'username' not in data: raise field_required('username') password = data.get('password') or DatabaseService.generate_password() result = DatabaseService.mysql_create_user( data['username'], password, data.get('host', 'localhost'), data.get('root_password') ) if result['success']: result['password'] = password # Grant privileges if database specified if data.get('database'): DatabaseService.mysql_grant_privileges( data['username'], data['database'], data.get('privileges', 'ALL'), data.get('host', 'localhost'), data.get('root_password') ) return jsonify(result), 201 if result['success'] else 400 @databases_bp.route('/mysql/users/', methods=['DELETE']) @admin_required def drop_mysql_user(username): """Drop a MySQL user.""" data = json_object() host = data.get('host', 'localhost') result = DatabaseService.mysql_drop_user(username, host, data.get('root_password')) return jsonify(result), 200 if result['success'] else 400 @databases_bp.route('/mysql/users//privileges', methods=['GET']) @admin_required def get_mysql_user_privileges(username): """Get privileges for a MySQL user. For security, root_password should be passed via X-DB-Password header. """ host = request.args.get('host', 'localhost') root_password = request.headers.get('X-DB-Password') privileges = DatabaseService.mysql_get_user_privileges(username, host, root_password) return jsonify({'privileges': privileges}), 200 @databases_bp.route('/mysql/users//grant', methods=['POST']) @admin_required def grant_mysql_privileges(username): """Grant privileges to a MySQL user.""" data = json_object() if not data or 'database' not in data: raise field_required('database') result = DatabaseService.mysql_grant_privileges( username, data['database'], data.get('privileges', 'ALL'), data.get('host', 'localhost'), data.get('root_password') ) return jsonify(result), 200 if result['success'] else 400 @databases_bp.route('/mysql/users//revoke', methods=['POST']) @admin_required def revoke_mysql_privileges(username): """Revoke privileges from a MySQL user.""" data = json_object() if not data or 'database' not in data: raise field_required('database') result = DatabaseService.mysql_revoke_privileges( username, data['database'], data.get('privileges', 'ALL'), data.get('host', 'localhost'), data.get('root_password') ) return jsonify(result), 200 if result['success'] else 400 # ==================== POSTGRESQL DATABASES ==================== @databases_bp.route('/postgresql', methods=['GET']) @admin_required def list_pg_databases(): """List PostgreSQL databases.""" databases = DatabaseService.pg_list_databases() return jsonify({'databases': databases}), 200 @databases_bp.route('/postgresql', methods=['POST']) @admin_required def create_pg_database(): """Create a PostgreSQL database.""" data = json_object() if not data or 'name' not in data: raise field_required('name') result = DatabaseService.pg_create_database( data['name'], data.get('owner'), data.get('encoding', 'UTF8') ) if result['success']: # Optionally create user with same name if data.get('create_user'): password = data.get('user_password') or DatabaseService.generate_password() DatabaseService.pg_create_user(data['name'], password) DatabaseService.pg_grant_privileges(data['name'], data['name'], 'ALL') result['user'] = data['name'] result['password'] = password _persist_provisioned('postgresql', data['name'], result, data) return jsonify(result), 201 if result['success'] else 400 @databases_bp.route('/postgresql/', methods=['DELETE']) @admin_required def drop_pg_database(name): """Drop a PostgreSQL database.""" result = DatabaseService.pg_drop_database(name) return jsonify(result), 200 if result['success'] else 400 @databases_bp.route('/postgresql//tables', methods=['GET']) @admin_required def get_pg_tables(name): """Get tables in a PostgreSQL database.""" tables = DatabaseService.pg_get_tables(name) return jsonify({'tables': tables}), 200 @databases_bp.route('/postgresql//backup', methods=['POST']) @admin_required def backup_pg_database(name): """Backup a PostgreSQL database.""" result = DatabaseService.pg_backup(name) return jsonify(result), 200 if result['success'] else 400 @databases_bp.route('/postgresql//restore', methods=['POST']) @admin_required def restore_pg_database(name): """Restore a PostgreSQL database from backup.""" data = json_object() if not data or 'backup_path' not in data: raise field_required('backup_path') result = DatabaseService.pg_restore(name, data['backup_path']) return jsonify(result), 200 if result['success'] else 400 # ==================== POSTGRESQL USERS ==================== @databases_bp.route('/postgresql/users', methods=['GET']) @admin_required def list_pg_users(): """List PostgreSQL users.""" users = DatabaseService.pg_list_users() return jsonify({'users': users}), 200 @databases_bp.route('/postgresql/users', methods=['POST']) @admin_required def create_pg_user(): """Create a PostgreSQL user.""" data = json_object() if not data or 'username' not in data: raise field_required('username') password = data.get('password') or DatabaseService.generate_password() result = DatabaseService.pg_create_user(data['username'], password) if result['success']: result['password'] = password # Grant privileges if database specified if data.get('database'): DatabaseService.pg_grant_privileges( data['username'], data['database'], data.get('privileges', 'ALL') ) return jsonify(result), 201 if result['success'] else 400 @databases_bp.route('/postgresql/users/', methods=['DELETE']) @admin_required def drop_pg_user(username): """Drop a PostgreSQL user.""" result = DatabaseService.pg_drop_user(username) return jsonify(result), 200 if result['success'] else 400 @databases_bp.route('/postgresql/users//grant', methods=['POST']) @admin_required def grant_pg_privileges(username): """Grant privileges to a PostgreSQL user.""" data = json_object() if not data or 'database' not in data: raise field_required('database') result = DatabaseService.pg_grant_privileges( username, data['database'], data.get('privileges', 'ALL') ) return jsonify(result), 200 if result['success'] else 400 @databases_bp.route('/postgresql/users//revoke', methods=['POST']) @admin_required def revoke_pg_privileges(username): """Revoke privileges from a PostgreSQL user.""" data = json_object() if not data or 'database' not in data: raise field_required('database') result = DatabaseService.pg_revoke_privileges( username, data['database'], data.get('privileges', 'ALL') ) return jsonify(result), 200 if result['success'] else 400 # ==================== BACKUPS ==================== @databases_bp.route('/backups', methods=['GET']) @admin_required def list_backups(): """List all database backups.""" db_type = request.args.get('type') backups = DatabaseService.list_backups(db_type) return jsonify({'backups': backups}), 200 @databases_bp.route('/backups/', methods=['DELETE']) @admin_required def delete_backup(filename): """Delete a backup file.""" result = DatabaseService.delete_backup(filename) return jsonify(result), 200 if result['success'] else 400 # ==================== QUERY EXECUTION ==================== @databases_bp.route('/mysql//query', methods=['POST']) @admin_required def execute_mysql_query(name): """Execute a query on a MySQL database. Request body: query: SQL query to execute readonly: If true, only allow SELECT/SHOW/DESCRIBE/EXPLAIN (default: true) Security: Readonly mode is enforced by default. Admin role required to disable. """ data = json_object() if not data or 'query' not in data: raise field_required('query') query = data['query'] readonly = data.get('readonly', True) root_password = request.headers.get('X-DB-Password') # Only admins can disable readonly mode if not readonly: require_admin_user() result = DatabaseService.mysql_execute_query( database=name, query=query, readonly=readonly, root_password=root_password, timeout=30, max_rows=1000 ) return jsonify(result), 200 if result['success'] else 400 @databases_bp.route('/postgresql//query', methods=['POST']) @admin_required def execute_pg_query(name): """Execute a query on a PostgreSQL database. Request body: query: SQL query to execute readonly: If true, only allow SELECT/SHOW/DESCRIBE/EXPLAIN (default: true) Security: Readonly mode is enforced by default. Admin role required to disable. """ data = json_object() if not data or 'query' not in data: raise field_required('query') query = data['query'] readonly = data.get('readonly', True) # Only admins can disable readonly mode if not readonly: require_admin_user() result = DatabaseService.pg_execute_query( database=name, query=query, readonly=readonly, timeout=30, max_rows=1000 ) return jsonify(result), 200 if result['success'] else 400 @databases_bp.route('/sqlite/query', methods=['POST']) @admin_required def execute_sqlite_query(): """Execute a query on a SQLite database file. Request body: path: Path to the SQLite database file query: SQL query to execute readonly: If true, only allow SELECT queries (default: true) Security: Readonly mode is enforced by default. Admin role required to disable. """ data = json_object() if not data or 'path' not in data or 'query' not in data: return jsonify({'error': 'path and query are required'}), 400 db_path = data['path'] query = data['query'] readonly = data.get('readonly', True) # Only admins can disable readonly mode if not readonly: require_admin_user() result = DatabaseService.sqlite_execute_query( db_path=db_path, query=query, readonly=readonly, timeout=30, max_rows=1000 ) return jsonify(result), 200 if result['success'] else 400 @databases_bp.route('/mysql//tables//structure', methods=['GET']) @admin_required def get_mysql_table_structure(name, table): """Get the structure/schema of a MySQL table.""" root_password = request.headers.get('X-DB-Password') result = DatabaseService.mysql_get_table_structure(name, table, root_password) return jsonify(result), 200 if result['success'] else 400 @databases_bp.route('/postgresql//tables/
/structure', methods=['GET']) @admin_required def get_pg_table_structure(name, table): """Get the structure/schema of a PostgreSQL table.""" result = DatabaseService.pg_get_table_structure(name, table) return jsonify(result), 200 if result['success'] else 400 @databases_bp.route('/sqlite/tables/
/structure', methods=['GET']) @admin_required def get_sqlite_table_structure(table): """Get the structure/schema of a SQLite table. Query params: path: Path to the SQLite database file """ db_path = request.args.get('path') if not db_path: return jsonify({'error': 'path query parameter is required'}), 400 result = DatabaseService.sqlite_get_table_structure(db_path, table) return jsonify(result), 200 if result['success'] else 400 @databases_bp.route('/sqlite', methods=['GET']) @admin_required def list_sqlite_databases(): """List SQLite database files found in common locations.""" databases = DatabaseService.sqlite_list_databases() return jsonify({'databases': databases}), 200 @databases_bp.route('/sqlite/tables', methods=['GET']) @admin_required def get_sqlite_tables(): """Get tables in a SQLite database. Query params: path: Path to the SQLite database file """ db_path = request.args.get('path') if not db_path: return jsonify({'error': 'path query parameter is required'}), 400 tables = DatabaseService.sqlite_get_tables(db_path) return jsonify({'tables': tables}), 200 # ==================== DOCKER CONTAINER DATABASES ==================== @databases_bp.route('/docker', methods=['GET']) @admin_required def list_docker_databases(): """List all databases running in Docker containers. This includes MySQL/MariaDB containers from template-deployed apps. """ containers = DatabaseService.list_docker_mysql_containers() return jsonify({'containers': containers}), 200 @databases_bp.route('/docker/databases', methods=['GET']) @jwt_required() def list_all_docker_databases(): """Flat list of databases discovered across every Docker app the caller can access, each tagged with its owning app and engine. Powers grouping Docker-hosted databases under their engine node — e.g. a containerised MySQL created by a WordPress stack appears under 'MySQL / MariaDB', not only buried under 'Docker apps'. """ current_user_id = get_jwt_identity() user = User.query.get(current_user_id) # Workspace-aware scoping (#33). Docker databases are app-children — scope the # enumeration to the apps the caller can see (own + granted, workspace-narrowed) # via the same helper apps/domains use. With no workspace context this is the # prior behavior. NOTE: the host-level MySQL/PostgreSQL/SQLite endpoints above # are server-global system resources with no app/workspace linkage, so they are # intentionally NOT workspace-scoped (their mutations are already admin-gated). from app.services.workspace_service import WorkspaceService ws_id = WorkspaceService.resolve_workspace_id( user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id')) app_q = WorkspaceService.scope_query( Application.query_active().filter_by(app_type='docker'), Application, user, workspace_id=ws_id, owner_attr='user_id', grant_resource_type='application') out = [] for app in app_q.all(): if not app.root_path: continue info = DatabaseService.get_app_database_info(app.name, app.root_path) if not info: continue for db in info: entry = dict(db) entry['app_id'] = app.id entry['app_name'] = app.name out.append(entry) return jsonify({'databases': out}), 200 @databases_bp.route('/docker/app/', methods=['GET']) @jwt_required() def get_app_databases(app_id): """Get database info for a Docker application. This reads the docker-compose.yml and .env files to find database containers and their credentials. """ current_user_id = get_jwt_identity() user = User.query.get(current_user_id) app = Application.query_active().filter_by(id=app_id).first() if not app: raise not_found('service') if not ResourceGrantService.can_access_app(user, app): raise permission_denied() if app.app_type != 'docker' or not app.root_path: return jsonify({'error': 'This service is not a Docker service'}), 400 db_info = DatabaseService.get_app_database_info(app.name, app.root_path) if not db_info: return jsonify({'databases': [], 'message': 'No database containers found'}), 200 return jsonify({'databases': db_info}), 200 @databases_bp.route('/docker//databases', methods=['GET']) @admin_required def list_docker_container_databases(container): """List databases in a Docker MySQL container.""" user = request.args.get('user', 'root') password = request.headers.get('X-DB-Password') databases = DatabaseService.docker_mysql_list_databases(container, user, password) return jsonify({'databases': databases}), 200 @databases_bp.route('/docker///tables', methods=['GET']) @admin_required def get_docker_database_tables(container, database): """Get tables in a Docker MySQL database.""" user = request.args.get('user', 'root') password = request.headers.get('X-DB-Password') result = DatabaseService.docker_mysql_get_tables(container, database, user, password) return jsonify({ 'tables': result['tables'], 'connected': result['connected'], 'error': result['error'], }), 200 @databases_bp.route('/docker///query', methods=['POST']) @admin_required def execute_docker_query(container, database): """Execute a query on a Docker MySQL database. Request body: query: SQL query to execute readonly: If true, only allow SELECT/SHOW/DESCRIBE/EXPLAIN (default: true) Security: Readonly mode is enforced by default. Admin role required to disable. """ data = json_object() if not data or 'query' not in data: raise field_required('query') query = data['query'] readonly = data.get('readonly', True) user = data.get('user', 'root') password = request.headers.get('X-DB-Password') or data.get('password') # Only admins can disable readonly mode if not readonly: require_admin_user() result = DatabaseService.docker_mysql_execute_query( container_name=container, database=database, query=query, user=user, password=password, readonly=readonly, timeout=30, max_rows=1000 ) return jsonify(result), 200 if result['success'] else 400 # ==================== MANAGED DATABASES ==================== # Durable tracking beside the live introspection above. Listing is available to # any authenticated user; mutations + credential reveal are admin-only. def _workspace_id(): from app.services.workspace_service import WorkspaceService user = get_current_user() return WorkspaceService.resolve_workspace_id( user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id')) @databases_bp.route('/managed', methods=['GET']) @jwt_required() def list_managed_databases(): """List tracked databases (credentials masked).""" rows = ManagedDatabaseService.list(workspace_id=_workspace_id()) return jsonify({'databases': [r.to_dict() for r in rows]}), 200 @databases_bp.route('/managed', methods=['POST']) @admin_required def create_managed_database(): """Provision a new database (mysql|postgresql) AND track it as managed.""" data = json_object() engine = (data.get('engine') or '').strip().lower() name = data.get('name') if not name: raise field_required('name') if engine not in ('mysql', 'postgresql'): return jsonify({'error': 'engine must be mysql or postgresql'}), 400 if engine == 'mysql': result = DatabaseService.mysql_create_database( name, data.get('charset', 'utf8mb4'), data.get('collation', 'utf8mb4_unicode_ci'), data.get('root_password')) if result.get('success') and data.get('create_user'): password = data.get('user_password') or DatabaseService.generate_password() DatabaseService.mysql_create_user(name, password, data.get('host', 'localhost'), data.get('root_password')) DatabaseService.mysql_grant_privileges(name, name, 'ALL', data.get('host', 'localhost'), data.get('root_password')) result['user'] = name result['password'] = password else: result = DatabaseService.pg_create_database(name, data.get('owner'), data.get('encoding', 'UTF8')) if result.get('success') and data.get('create_user'): password = data.get('user_password') or DatabaseService.generate_password() DatabaseService.pg_create_user(name, password) DatabaseService.pg_grant_privileges(name, name, 'ALL') result['user'] = name result['password'] = password if not result.get('success'): return jsonify(result), 400 managed = ManagedDatabaseService.record_provisioned( engine, name, admin_username=result.get('user'), admin_secret=result.get('password'), owner_application_id=data.get('application_id'), workspace_id=_workspace_id(), ) return jsonify({'database': managed.to_dict()}), 201 @databases_bp.route('/managed/adopt', methods=['POST']) @admin_required def adopt_managed_database(): """Track a live-discovered database that isn't tracked yet.""" data = json_object() engine = (data.get('engine') or '').strip().lower() name = data.get('name') if not name or engine not in ('mysql', 'postgresql', 'mongodb'): return jsonify({'error': 'engine (mysql|postgresql|mongodb) and name are required'}), 400 managed = ManagedDatabaseService.adopt( engine, data.get('host', 'localhost'), name, port=data.get('port'), host_kind=data.get('host_kind', 'host'), container_ref=data.get('container_ref'), admin_username=data.get('admin_username'), admin_secret=data.get('admin_secret'), owner_application_id=data.get('application_id'), workspace_id=_workspace_id(), ) return jsonify({'database': managed.to_dict()}), 201 @databases_bp.route('/managed/', methods=['GET']) @jwt_required() def get_managed_database(managed_id): """Detail + a best-effort live-sync state (does the DB still exist?).""" managed = ManagedDatabaseService.get(managed_id) if not managed: raise not_found('database') data = managed.to_dict() data['sync'] = ManagedDatabaseService.sync_state(managed) return jsonify({'database': data}), 200 @databases_bp.route('/managed/', methods=['DELETE']) @admin_required def delete_managed_database(managed_id): """Untrack a managed database (optional ``?drop=true`` to also DROP it).""" managed = ManagedDatabaseService.get(managed_id) if not managed: raise not_found('database') drop = str(request.args.get('drop', '')).lower() in ('1', 'true', 'yes') from flask_jwt_extended import get_jwt_identity ManagedDatabaseService.delete(managed, drop=drop, user_id=get_jwt_identity()) return jsonify({'success': True, 'dropped': drop}), 200 @databases_bp.route('/managed//connection-uri', methods=['POST']) @admin_required def reveal_managed_connection_uri(managed_id): """Reveal the real connection string (audited).""" from app.models.audit_log import AuditLog from app.services.audit_service import AuditService managed = ManagedDatabaseService.get(managed_id) if not managed: raise not_found('database') uri = ManagedDatabaseService.build_connection_uri(managed, reveal=True) AuditService.log( action=getattr(AuditLog, 'ACTION_SECRET_REVEALED', 'secret.revealed'), user_id=get_jwt_identity(), target_type='managed_database', target_id=managed.id, details={'engine': managed.engine, 'name': managed.name}, ) return jsonify({'connection_uri': uri}), 200 @databases_bp.route('/managed//protect', methods=['POST']) @admin_required def protect_managed_database(managed_id): """Create/refresh a BackupPolicy for a managed database (real FK target).""" managed = ManagedDatabaseService.get(managed_id) if not managed: raise not_found('database') data = json_object() policy = ManagedDatabaseService.protect(managed, fields=data.get('policy')) return jsonify({'policy': policy.to_dict()}), 201 # ==================== PROCESSES ==================== # Live SHOW PROCESSLIST / pg_stat_activity per server or container, with an # admin-only kill/terminate action. Targets mirror the explorer's connection # shapes (host engine vs docker container). def _process_error_response(result): # The services tag the one client-caused failure with a code; the status # never depends on the wording of the error. code = 400 if result.get('code') == 'unsupported_engine' else 502 return jsonify({'error': result['error']}), code @databases_bp.route('/mysql/processes', defaults={'engine': 'mysql'}, methods=['GET']) @databases_bp.route('/postgresql/processes', defaults={'engine': 'postgresql'}, methods=['GET']) @admin_required def list_host_db_processes(engine): """List live server processes on a host engine.""" target = {'engine': engine, 'password': request.headers.get('X-DB-Password')} result = DbProcessService.list_processes(target) if 'error' in result: return _process_error_response(result) return jsonify(result), 200 @databases_bp.route('/mysql/processes//kill', defaults={'engine': 'mysql'}, methods=['POST']) @databases_bp.route('/postgresql/processes//kill', defaults={'engine': 'postgresql'}, methods=['POST']) @admin_required def kill_host_db_process(engine, pid): """Kill/terminate a process on a host engine (admin only).""" target = {'engine': engine, 'password': request.headers.get('X-DB-Password')} result = DbProcessService.kill_process(target, pid) if 'error' in result: return _process_error_response(result) return jsonify(result), 200 @databases_bp.route('/docker//processes', methods=['GET']) @admin_required def list_docker_db_processes(container): """List live server processes inside a Docker database container.""" target = { 'engine': request.args.get('type', 'mysql'), 'container': container, 'user': request.args.get('user'), 'password': request.headers.get('X-DB-Password'), 'database': request.args.get('database'), } result = DbProcessService.list_processes(target) if 'error' in result: return _process_error_response(result) return jsonify(result), 200 @databases_bp.route('/docker//processes//kill', methods=['POST']) @admin_required def kill_docker_db_process(container, pid): """Kill/terminate a process inside a Docker database container (admin only).""" data = json_object(optional=True) target = { 'engine': data.get('type') or request.args.get('type', 'mysql'), 'container': container, 'user': data.get('user'), 'password': request.headers.get('X-DB-Password') or data.get('password'), 'database': data.get('database'), } result = DbProcessService.kill_process(target, pid) if 'error' in result: return _process_error_response(result) return jsonify(result), 200 # ==================== INSIGHTS ==================== # Read-only top queries + connection / cache-hit gauges for a Docker database # container, and the one write it offers: turning pg_stat_statements on # (through the config tuner, so its rollback undoes it). def _docker_db_target(container, source): return { 'engine': source.get('type') or 'mysql', 'container': container, 'user': source.get('user'), 'password': request.headers.get('X-DB-Password'), 'database': source.get('database'), } @databases_bp.route('/docker//insights', methods=['GET']) @admin_required def docker_db_insights(container): from app.services.db_insights_service import DbInsightsService result = DbInsightsService.insights(_docker_db_target(container, request.args)) if 'error' in result: return _process_error_response(result) return jsonify(result), 200 @databases_bp.route('/docker//insights/pg-stat-statements', methods=['POST']) @admin_required def enable_pg_stat_statements(container): from app.services.db_config_tuner_service import DbConfigTunerService data = json_object(optional=True) target = _docker_db_target(container, dict(data, type='postgresql')) return jsonify(DbConfigTunerService.enable_pg_stat_statements(target)), 200 # ==================== UTILITY ==================== @databases_bp.route('/generate-password', methods=['GET']) @jwt_required() def generate_password(): """Generate a secure random password.""" # Bounded: an unchecked ?length=1000000000 made the server build a # gigabyte-long string. length = min(max(request.args.get('length', 16, type=int), 8), 128) password = DatabaseService.generate_password(length) return jsonify({'password': password}), 200