From 9c9d9d70d6b641d60427e9679e64a94870820d82 Mon Sep 17 00:00:00 2001 From: Nitsu45 Date: Sat, 24 Oct 2026 12:33:41 +0200 Subject: [PATCH] Support multiple allowlisted databases in project research tools --- README.md | 17 ++ config/project.example.toml | 6 + config/project.vcore-multidb.example.toml | 265 ++++++++++++++++++ docs/project-configuration.md | 49 ++++ docs/project-research-v1.md | 2 +- .../multidatabase-research-20261024.md | 66 +++++ .../analysis/entity_resolution.py | 4 +- .../analysis/tool_guidance.py | 4 + src/viano_bug_analysis/deployment.py | 2 +- ...nvestigation-project-output-v1.schema.json | 8 +- .../investigation-request-v1.schema.json | 8 +- .../investigation-state-v1.schema.json | 8 +- .../schemas/project-capture-v1.schema.json | 8 +- .../schemas/project-query-v1.schema.json | 4 +- .../tools/context_expansion.py | 18 +- .../tools/database_relations.py | 24 +- src/viano_bug_analysis/tools/project_query.py | 6 +- src/viano_bug_analysis/tools/sql_database.py | 102 ++++++- src/viano_bug_analysis/web/user-manual.json | 2 +- tests/test_multidatabase_research.py | 190 +++++++++++++ 20 files changed, 740 insertions(+), 53 deletions(-) create mode 100644 config/project.vcore-multidb.example.toml create mode 100644 docs/test-results/multidatabase-research-20261024.md create mode 100644 tests/test_multidatabase_research.py diff --git a/README.md b/README.md index f8bd734..befab8b 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,23 @@ In `project.toml` die Null-Commit- und `CONFIGURE_ME`-Platzhalter ersetzen. Die vollständige Vorlage mit Relations-, Enrichment- und Werkzeugregeln beibehalten; die Projektdatei nicht auf DB-Adresse und Sourcepfad zusammenkürzen. +Für mehrere Datenbanken derselben MariaDB-Instanz kann in `project.toml` ein +zusätzlicher Abschnitt ergänzt werden: + +```toml +[database.additional_databases] +cata_hotfixes = ["*"] +cata_locale = ["*"] +``` + +`[database].database` bleibt beispielsweise `cata_world`, `user` kann +`buganalysis` sein. Für den DB-Benutzer müssen entsprechende SELECT-Grants +vorliegen. `["*"]` erlaubt ausdrücklich alle sichtbaren Tabellen der jeweiligen +DB. Alternativ konkrete Tabellennamen auflisten. Nicht konfigurierte Datenbanken +bleiben gesperrt. Einzeldatenbank-Konfigurationen bleiben gültig. +Vollständige Mehrdatenbank-Vorlage: +[project.vcore-multidb.example.toml](config/project.vcore-multidb.example.toml). + Bei Verwendung der gelieferten Gemini-/DB-Vorlagen enthält `worker.env`: ```text diff --git a/config/project.example.toml b/config/project.example.toml index 4ea972a..5c50919 100644 --- a/config/project.example.toml +++ b/config/project.example.toml @@ -19,6 +19,12 @@ snapshot_id = "backup-sha256:4964f277ef5cf7f7f98bf4378971dcb55180bac813d49bcda6d # Explicit allowlist: no account or character tables. tables = ["creature_template", "creature_template_locale", "creature", "quest_template", "quest_template_addon", "smart_scripts", "conditions", "game_event", "game_event_creature", "spell_script_names", "gameobject_template", "gameobject", "creature_equip_template", "item_script_names", "creature_queststarter", "spell_dbc"] +# Optional additional databases on this same connection; enable explicitly: +# [database.additional_databases] +# cata_hotfixes = ["*"] +# cata_locale = ["*"] +# Use individual table names instead of "*" for narrower access. + [limits] max_rows = 50 max_result_bytes = 65536 diff --git a/config/project.vcore-multidb.example.toml b/config/project.vcore-multidb.example.toml new file mode 100644 index 0000000..0ea84a8 --- /dev/null +++ b/config/project.vcore-multidb.example.toml @@ -0,0 +1,265 @@ +# Operator-controlled bindings. The model cannot override these settings. +relations_file = "project-relations.vcore.toml" +[project] +type = "wow" +profile = "vcore" + +[source] +path = "/srv/viano-bug-analysis/viano-core" +url = "https://gitea.viano-software.de/Nico/Viano-Core" +commit = "0000000000000000000000000000000000000000" + +[database] +host = "127.0.0.1" +port = 3306 +database = "cata_world" +user = "buganalysis" +password_env = "VIANO_DB_PASSWORD" +snapshot_id = "CONFIGURE_ME" +# Explicit allowlist: no account or character tables. +tables = ["*"] + +# Explicit database scope: no auth, character or locale-reference access. +[database.additional_databases] +cata_hotfixes = ["*"] +cata_locale = ["*"] + +[limits] +max_rows = 50 +max_result_bytes = 65536 +max_files = 20000 +max_file_bytes = 2097152 +max_search_bytes = 134217728 +max_lines = 80 +timeout_seconds = 15 +max_cached_queries = 256 + +[enrichment] +max_queries = 24 +max_depth = 3 +max_relationships = 128 + +[[enrichment.texts]] +table = "quest_template" +id_column = "ID" +context_role = "quest_definition" +columns = ["LogTitle", "LogDescription", "QuestDescription"] + +[[enrichment.queries]] +kind = "source.schema" +text = "CREATE TABLE `creature`" +path_prefix = "sql/base/" +case_sensitive = true +offset = 0 +limit = 1 + +[[enrichment.queries]] +kind = "source.schema" +text = "CREATE TABLE `quest_template`" +path_prefix = "sql/base/" +case_sensitive = true +offset = 0 +limit = 1 + +# Deterministic entity resolution before the first LLM call; no diagnosis. +[[enrichment.entities]] +entity_type = "creature" +table = "creature_template" +column = "entry" +name_column = "name" +columns = ["entry", "name", "subname", "ScriptName", "AIName"] +limit = 1 + +[[enrichment.entities]] +entity_type = "quest" +table = "quest_template" +column = "ID" +name_column = "LogTitle" +columns = ["ID", "LogTitle", "LogDescription", "QuestDescription", "StartItem", "RequiredNpcOrGo1", "RequiredNpcOrGo2", "RequiredNpcOrGo3", "RequiredNpcOrGo4", "RequiredItemId1", "RequiredItemId2", "RequiredItemId3", "RequiredItemId4", "RequiredItemId5", "RequiredItemId6", "RequiredSpell"] +limit = 1 + +[[enrichment.relationships]] +from_table = "quest_template" +from_id_column = "ID" +from_column = "StartItem" +relation = "quest_start_item" +entity_type = "item" +table = "item_script_names" +column = "Id" +columns = ["Id", "ScriptName"] + +[[enrichment.relationships]] +from_table = "quest_template" +from_id_column = "ID" +from_column = "RequiredItemId1" +relation = "quest_required_item" +entity_type = "item" +table = "item_script_names" +column = "Id" +columns = ["Id", "ScriptName"] + +[[enrichment.relationships]] +from_table = "quest_template" +from_id_column = "ID" +from_column = "RequiredItemId2" +relation = "quest_required_item" +entity_type = "item" +table = "item_script_names" +column = "Id" +columns = ["Id", "ScriptName"] + +[[enrichment.relationships]] +from_table = "quest_template" +from_id_column = "ID" +from_column = "ID" +relation = "quest_starter_records" +table = "creature_queststarter" +column = "quest" +columns = ["id", "quest"] + +[[enrichment.relationships]] +from_table = "creature_queststarter" +from_id_column = "quest" +from_column = "id" +relation = "quest_giver" +entity_type = "creature" +table = "creature_template" +column = "entry" +columns = ["entry", "name", "AIName", "ScriptName"] + +[[enrichment.relationships]] +from_table = "creature_template" +from_id_column = "entry" +from_column = "entry" +when_column = "AIName" +when_value = "SmartAI" +relation = "creature_smartai_records" +table = "smart_scripts" +column = "entryorguid" +columns = ["entryorguid", "source_type", "id", "link", "event_type", "event_param1", "event_param2", "event_param3", "event_param4", "action_type", "action_param1", "action_param2", "action_param3", "action_param4", "action_param5", "action_param6", "target_type", "target_param1", "target_param2", "target_param3", "comment"] +filters = [{column = "source_type", operator = "eq", value = 0}] +limit = 10 + +[[enrichment.relationships]] +from_table = "creature_template" +from_id_column = "entry" +from_column = "ScriptName" +relation = "creature_script_binding" +kind = "source.symbol" +path_prefix = "src/server/scripts/" +limit = 5 + +[[enrichment.relationships]] +from_table = "item_script_names" +from_id_column = "Id" +from_column = "ScriptName" +relation = "item_script_binding" +kind = "source.symbol" +path_prefix = "src/server/scripts/" +limit = 5 + +[[enrichment.relationships]] +from_table = "quest_template" +from_id_column = "ID" +from_column = "RequiredSpell" +relation = "quest_required_spell" +entity_type = "spell" +table = "spell_script_names" +column = "spell_id" +columns = ["spell_id", "ScriptName"] + +[[enrichment.relationships]] +from_table = "spell_script_names" +from_id_column = "spell_id" +from_column = "ScriptName" +relation = "spell_script_binding" +kind = "source.symbol" +path_prefix = "src/server/scripts/" +limit = 5 + +[[enrichment.relationships]] +from_table = "quest_template" +from_id_column = "ID" +from_column = "RequiredItemId3" +relation = "quest_required_item" +entity_type = "item" +table = "item_script_names" +column = "Id" +columns = ["Id", "ScriptName"] + +[[enrichment.relationships]] +from_table = "quest_template" +from_id_column = "ID" +from_column = "RequiredItemId4" +relation = "quest_required_item" +entity_type = "item" +table = "item_script_names" +column = "Id" +columns = ["Id", "ScriptName"] + +[[enrichment.relationships]] +from_table = "quest_template" +from_id_column = "ID" +from_column = "RequiredItemId5" +relation = "quest_required_item" +entity_type = "item" +table = "item_script_names" +column = "Id" +columns = ["Id", "ScriptName"] + +[[enrichment.relationships]] +from_table = "quest_template" +from_id_column = "ID" +from_column = "RequiredItemId6" +relation = "quest_required_item" +entity_type = "item" +table = "item_script_names" +column = "Id" +columns = ["Id", "ScriptName"] + +# Semantic name lookup runs in Case Interpretation, never in the deterministic CaseBuilder. +[entity_resolution] +enabled = true +max_searches = 4 +max_candidates = 8 +max_invalid_responses = 3 + +[[entity_resolution.catalogs]] +id = "creatures" +entity_type = "creature" +table = "creature_template" +id_column = "entry" +name_column = "name" +columns = ["entry", "name", "subname", "AIName", "ScriptName", "minlevel", "maxlevel", "faction", "unit_flags"] + +[[entity_resolution.catalogs]] +id = "localized_creatures" +entity_type = "creature" +table = "creature_template_locale" +id_column = "entry" +name_column = "Name" +columns = ["entry", "locale", "Name", "Title"] + +# Larger read-only source tool; registration naming is project-specific. +[source_analysis] +enabled = true +context_lines = 8 +max_searches = 4 +max_matches = 12 +max_files = 8 +max_blocks = 12 +max_total_lines = 160 +registration_prefixes = ["AddSC_"] + +# Context-aware source selection. Domain markers belong to this adapter. +protocol = 2 +max_seed_terms = 8 +max_candidate_matches = 50 +max_weak_hits = 8 +code_prefixes = ["src/"] +schema_prefixes = ["sql/base/"] +historical_prefixes = ["sql/old/", "sql/archive/"] +context_fact_names = ["zone_name", "area_name", "world_event_name"] +type_markers = {creature = ["NPC", "CREATURE"], quest = ["QUEST"], spell = ["SPELL"], item = ["ITEM"], gameobject = ["GO", "GAMEOBJECT"]} +entity_tables = [{table = "creature_template", entity_type = "creature", id_column = "entry", script_columns = ["ScriptName"]}] +focus_roles = ["diagnostic_focus", "explicit_quest"] diff --git a/docs/project-configuration.md b/docs/project-configuration.md index 044bae6..e00f266 100644 --- a/docs/project-configuration.md +++ b/docs/project-configuration.md @@ -81,6 +81,55 @@ Nur eine dieser Varianten setzen. Unter Linux lädt der Worker beispielsweise Passwörter nicht in Fallakten, LLM-Prompts oder Versionsverwaltung ablegen. Die LLM bekommt Werkzeugergebnisse statt Zugangsdaten. +## Mehrere Datenbanken pro Projekt + +Eine Projektverbindung kann mehrere Datenbanken derselben MariaDB-Instanz +ansprechen. Host, Port, DB-Benutzer und Passwortbezug werden gemeinsam verwendet. +`database` benennt die Standarddatenbank; unqualifizierte Tabellennamen beziehen +sich ausschließlich auf diese. Zusätzliche Datenbanken werden ausdrücklich +freigegeben, etwa für Vcore: + +```toml +# Im vorhandenen [database]-Abschnitt: database = "cata_world", user = "buganalysis" +[database.additional_databases] +cata_hotfixes = ["*"] +cata_locale = ["*"] +``` + +Den neuen Abschnitt beispielsweise am Ende der vorhandenen Datei ergänzen. +`[database]` nicht ein zweites Mal anlegen und die übrigen Projektregeln behalten. +Die vollständige alternative Vorlage steht unter +`config/project.vcore-multidb.example.toml`. + +`["*"]` gibt ausdrücklich alle metadaten-sichtbaren Tabellen der genannten DB +frei. Alternativ eine nichtleere Liste konkreter Tabellen verwenden. Auch die +Standarddatenbank unterstützt `tables = ["*"]`; die bisherige konkrete Liste +bleibt genauso gültig. Ein leeres Array ist keine pauschale Freigabe. +Die tatsächlich gewährten DB-Rechte bleiben zusätzlich maßgeblich. +Nicht aufgeführte Datenbanken sind selbst bei weitergehenden DB-Grants gesperrt. +In diesem Beispiel sind `cata_auth`, `cata_characters` und +`cata_locale_reference` nicht für Recherche freigegeben. + +Werkzeuge verwenden in zusätzlichen DBs qualifizierte Tabellennamen wie +`cata_locale.NAME_DER_TABELLE`. `database.tables` entdeckt die zugänglichen Namen +über alle freigegebenen DBs; Suchtext und Seitengrenzen gelten weiterhin. +Gleichnamige Tabellen werden nicht verwechselt. Die Datenbank steht in der +Belegherkunft und bei Mehrdatenbank-Ergebnissen auch in den Ergebniseinträgen. +Die LLM erhält keine Serveradresse, Anmeldenamen oder Passwörter. + +Namenskataloge, Enrichment-Regeln und Beziehungsprofile können ebenfalls +qualifizierte Tabellen referenzieren. Die Beziehungsanalyse führt die +konfigurierten Schritte innerhalb einer gemeinsamen lesenden Transaktion aus; +sie prüft die Transaktionsfähigkeit der beteiligten Tabellen. Dieses Format +konfiguriert keine unterschiedlichen DB-Server oder getrennten Zugangsdaten +innerhalb derselben Projektverbindung. + +Die Bereitschaftsprüfung prüft alle freigegebenen Tabellen einschließlich +Wildcard-Inventaren. Eine nicht erreichbare oder nicht lesbare zusätzliche DB +wird nicht stillschweigend ausgelassen. Bestehende Einzel-DB-Konfigurationen +benötigen keine Änderung. Bei aktiven Fallakten trotzdem die bestehende +Konfigurationsbindung beachten; ein Update setzt keine Budgets zurück. + ## Weitere Konfigurationen | Datei | Zuständigkeit | diff --git a/docs/project-research-v1.md b/docs/project-research-v1.md index 12be96b..0ad2b24 100644 --- a/docs/project-research-v1.md +++ b/docs/project-research-v1.md @@ -36,7 +36,7 @@ und `arguments` gemäß `project-query-v1.schema.json`. | `source.registration` | Symbolvorkommen als Ausgangspunkt einer Registrierungsprüfung | | `source.schema` | Literale Suche ausschließlich in SQL-Dateien | | `source.read` | Begrenzten Zeilenbereich einer bekannten Datei lesen | -| `database.tables` | Namen in der konfigurierten Tabellen-Allowlist finden | +| `database.tables` | Namen in den konfigurierten Datenbanken und Tabellenfreigaben finden | | `database.schema` | Tatsächliche Spalten, Typen, Nullbarkeit und Schlüssel lesen | | `database.rows` | Spalten auswählen, explizite Filter und begrenzte Seite lesen | | `database.count` | Vollständige Trefferzahl für dieselben Filter bestimmen | diff --git a/docs/test-results/multidatabase-research-20261024.md b/docs/test-results/multidatabase-research-20261024.md new file mode 100644 index 0000000..bc998be --- /dev/null +++ b/docs/test-results/multidatabase-research-20261024.md @@ -0,0 +1,66 @@ +# Mehrere Recherche-Datenbanken pro Projekt – 2026-10-24 + +Ausbau des SQL-Providers um ausdrücklich konfigurierte zusätzliche Datenbanken +derselben MariaDB-Verbindung. Standarddatenbank und alte Einzel-DB-Konfigurationen +bleiben gültig. Zusätzliche Tabellen verwenden `datenbank.tabelle`; es gibt keine +automatische Suche nach gleichnamigen Tabellen in anderen DBs. + +`[database.additional_databases]` unterstützt konkrete Tabellenlisten oder +ausdrücklich `["*"]` für metadaten-sichtbare Tabellen einer benannten DB. +Die Standardliste `tables` unterstützt dieselbe ausdrückliche Wildcard. +Keine implizite Freigabe anderer DBs, keine beliebigen SQL-Befehle, keine +Schreiboperationen. Separate Server/Zugänge innerhalb einer Projektverbindung +sind nicht Bestandteil dieses Formats. + +Angepasst: SQL-Auflösung und Metadaten-Caches, Werkzeug-/Capture-/Zustandsschemata, +Belegherkunft, DB-Beziehungsanalyse über mehrere Schemas in einer lesenden +Repeatable-Read-Transaktion, Entity-Namenskataloge, Enrichment-Tabellenvergleiche, +Toolhinweise und vollständige Bereitschaftsprüfung. Vcore-Mehrdatenbankvorlage, +README, Projektanleitung und gebündelte Webanleitung aktualisiert. + +## Erfolgreiche lokale Prüfungen + +```text +python -m pytest tests/test_multidatabase_research.py tests/test_context_expansion.py tests/test_entity_resolution.py tests/test_database_fact_grounding.py tests/test_deployment.py tests/test_user_manual.py -q +84 passed, 1 dependency deprecation warning +``` + +Davon 14 Mehrdatenbank-Testfälle einschließlich parametrisierter Ablehnungen. +Reale SQL-Selektion über getrennte SQLite-Attach-Schemas; MariaDB-Metadaten und +Transaktionsbefehle werden im Adapter simuliert. Geprüft: identische Tabellennamen +mit verschiedenen Daten, korrekte Schema-/Belegzuordnung, Abweisung nicht +freigegebener Schemas vor Verbindungsaufbau, Wildcard-Entdeckung und Pagination, +keine Auth-/Charakter-/Locale-Reference-Freigabe, gemeinsame Beziehungstransaktion, +Entity-/Enrichment-Regeln, vollständige Paket-/Evidence-Validierung, +Mehrdatenbank-Startprüfung mit fehlender DB und vollständige Vcore-Vorlage. +Keine Secrets oder Host-/Benutzerdaten im Provider-Binding. + +## Bekannte Fehler und Gegenprobe + +Zusätzlicher Lauf über Mehrdatenbank-, Relations-, ProjectResearch-, Deployment- +und Manual-Tests vor dem letzten Vorlagentest: 73 bestanden, 1 übersprungen, +5 fehlgeschlagen. Dieselben fünf Tests scheitern auch am unveränderten HEAD +9408af5 (52 bestanden, 1 übersprungen). Die Gegenprobe nutzte eine temporäre +separate Baseline-Sourcekopie; Arbeitsdateien wurden nicht zurückgesetzt. + +- test_database_relations.py::test_hybrid_loop_uses_relations_with_fixture_llm_and_can_resume +- test_project_research.py::test_model_loop_uses_project_tools_and_compact_previous_result[True] +- test_project_research.py::test_model_loop_uses_project_tools_and_compact_previous_result[unknown] +- test_project_research.py::test_model_loop_uses_project_tools_and_compact_previous_result[upgrade] +- test_project_research.py::test_started_project_loop_keeps_legacy_prompt_protocol + +Diese Alt-Schleifenprüfungen bleiben offen. Die harte Promptgrenze wurde nicht +gelockert; es wurden keine Budgets zurückgesetzt oder Tests zum Bestehen verändert. + +## Messumfang und Grenzen + +Alle Modellantworten in lokalen Workflow-Fixtures sind Testdoubles; keine echten +LLM-Anfragen. Keine Cloudkosten oder neuen Token-/Cache-Abrechnungsmetriken. +Keine Verbindung zum VPS, keine Änderungen an echten DBs, kein Dienststart. +Echte MariaDB-Verbindungen und Serverrechte werden nach dem Update mit der +Bereitschaftsprüfung auf dem Zielserver kontrolliert. + +Vor Testbeginn Trials 16.939.129.428 Bytes, Fehlerkorpus 2.471.041 Bytes und kleine +pytest-Temporärdaten. Nur kleine Source-/SQL-/Case-Fixtures; das 20-GB-Budget bleibt +eingehalten. Temporäre Baseline-Kopie nach Gegenprobe entfernt. Keine vollständigen +LLM-Testarchive angelegt. `docs/architecture.md` unverändert, kein Push. diff --git a/src/viano_bug_analysis/analysis/entity_resolution.py b/src/viano_bug_analysis/analysis/entity_resolution.py index 8cec3f8..34875a1 100644 --- a/src/viano_bug_analysis/analysis/entity_resolution.py +++ b/src/viano_bug_analysis/analysis/entity_resolution.py @@ -26,6 +26,7 @@ def digest(value): def policy(settings): + from viano_bug_analysis.tools.sql_database import is_table_reference settings = copy.deepcopy(settings) if set(settings) - {"enabled", "max_searches", "max_candidates", "max_invalid_responses", "catalogs"}: raise InterpretationError("ENTITY_RESOLUTION_CONFIG") @@ -41,7 +42,8 @@ def policy(settings): for c in result["catalogs"]: if (not isinstance(c, dict) or set(c) != {"id", "entity_type", "table", "id_column", "name_column", "columns"} or any(not isinstance(c[k], str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,63}", c[k]) - for k in ("id", "entity_type", "table", "id_column", "name_column")) + for k in ("id", "entity_type", "id_column", "name_column")) + or not is_table_reference(c.get('table')) or c["id"] in seen or not isinstance(c["columns"], list) or not 1 <= len(c["columns"]) <= 32 or any(not isinstance(v, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,63}", v) for v in c["columns"]) or not {c["id_column"], c["name_column"]} <= set(c["columns"])): diff --git a/src/viano_bug_analysis/analysis/tool_guidance.py b/src/viano_bug_analysis/analysis/tool_guidance.py index 68c4737..63e7533 100644 --- a/src/viano_bug_analysis/analysis/tool_guidance.py +++ b/src/viano_bug_analysis/analysis/tool_guidance.py @@ -38,6 +38,10 @@ def instructions(context): bindings = context['tool_schema'].get('x-project-bindings', {}) text = (HEADER + 'Use operation="project.query" for these arguments. Prefer a matching larger tool ' 'before separate schema/search/read calls; primitive tools remain available.\n') + if (bindings.get('database') or {}).get('additional_databases'): + text+=('Database table references use configured_database.table. Unqualified tables use only ' + 'the default database. Use database.tables to discover allowed tables across configured ' + 'databases; never substitute an equally named table from another database.\n') if bindings.get('relation_profiles'): text += ('database.relations arguments: {"kind":"database.relations",' '"profile":"NAME_FROM_relation_profiles","value":123}. ' diff --git a/src/viano_bug_analysis/deployment.py b/src/viano_bug_analysis/deployment.py index 6e13fec..8f1f641 100644 --- a/src/viano_bug_analysis/deployment.py +++ b/src/viano_bug_analysis/deployment.py @@ -42,7 +42,7 @@ def check(role,path,*,offline=False,loader=load_project): checks.append('profile_and_cloud_credentials_configured') if not offline: research.source.inventory() # pinned Git objects, no fetch or checkout - for table in research.database.tables: + for table in getattr(research.database,'schema_targets',research.database.tables): research.database.query({'kind':'database.schema','table':table}) checks+=['pinned_source_readable','allowed_database_schemas_readable'] return {'status':'CONFIG_READY' if offline else 'READY','role':role,'checks':checks, diff --git a/src/viano_bug_analysis/schemas/investigation-project-output-v1.schema.json b/src/viano_bug_analysis/schemas/investigation-project-output-v1.schema.json index 485304f..c71daa9 100644 --- a/src/viano_bug_analysis/schemas/investigation-project-output-v1.schema.json +++ b/src/viano_bug_analysis/schemas/investigation-project-output-v1.schema.json @@ -593,8 +593,8 @@ }, "table": { "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", - "maxLength": 64 + "pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$", + "maxLength": 129 } } }, @@ -618,8 +618,8 @@ }, "table": { "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", - "maxLength": 64 + "pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$", + "maxLength": 129 }, "columns": { "type": "array", diff --git a/src/viano_bug_analysis/schemas/investigation-request-v1.schema.json b/src/viano_bug_analysis/schemas/investigation-request-v1.schema.json index f24ab8b..5f3559a 100644 --- a/src/viano_bug_analysis/schemas/investigation-request-v1.schema.json +++ b/src/viano_bug_analysis/schemas/investigation-request-v1.schema.json @@ -507,8 +507,8 @@ }, "table": { "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", - "maxLength": 64 + "pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$", + "maxLength": 129 } } }, @@ -532,8 +532,8 @@ }, "table": { "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", - "maxLength": 64 + "pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$", + "maxLength": 129 }, "columns": { "type": "array", diff --git a/src/viano_bug_analysis/schemas/investigation-state-v1.schema.json b/src/viano_bug_analysis/schemas/investigation-state-v1.schema.json index 318c01c..5368fc2 100644 --- a/src/viano_bug_analysis/schemas/investigation-state-v1.schema.json +++ b/src/viano_bug_analysis/schemas/investigation-state-v1.schema.json @@ -1534,8 +1534,8 @@ }, "table": { "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", - "maxLength": 64 + "pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$", + "maxLength": 129 } } }, @@ -1559,8 +1559,8 @@ }, "table": { "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", - "maxLength": 64 + "pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$", + "maxLength": 129 }, "columns": { "type": "array", diff --git a/src/viano_bug_analysis/schemas/project-capture-v1.schema.json b/src/viano_bug_analysis/schemas/project-capture-v1.schema.json index f95c555..114a115 100644 --- a/src/viano_bug_analysis/schemas/project-capture-v1.schema.json +++ b/src/viano_bug_analysis/schemas/project-capture-v1.schema.json @@ -62,8 +62,8 @@ }, "table": { "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", - "maxLength": 64 + "pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$", + "maxLength": 129 } } }, @@ -87,8 +87,8 @@ }, "table": { "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", - "maxLength": 64 + "pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$", + "maxLength": 129 }, "columns": { "type": "array", diff --git a/src/viano_bug_analysis/schemas/project-query-v1.schema.json b/src/viano_bug_analysis/schemas/project-query-v1.schema.json index dd4ebb7..d716efd 100644 --- a/src/viano_bug_analysis/schemas/project-query-v1.schema.json +++ b/src/viano_bug_analysis/schemas/project-query-v1.schema.json @@ -5,9 +5,9 @@ "kind":{"enum":["source.files","database.tables"]},"text":{"type":"string","maxLength":200}, "offset":{"type":"integer","minimum":0,"maximum":20000},"limit":{"type":"integer","minimum":1}}}, {"type":"object","additionalProperties":false,"required":["kind","table"],"properties":{ - "kind":{"const":"database.schema"},"table":{"$ref":"#/$defs/identifier"}}}, + "kind":{"const":"database.schema"},"table":{"type":"string","pattern":"^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$","maxLength":129}}}, {"type":"object","additionalProperties":false,"required":["kind","table","columns","filters","offset","limit"],"properties":{ - "kind":{"enum":["database.rows","database.count"]},"table":{"$ref":"#/$defs/identifier"}, + "kind":{"enum":["database.rows","database.count"]},"table":{"type":"string","pattern":"^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$","maxLength":129}, "columns":{"type":"array","uniqueItems":true,"maxItems":30,"items":{"$ref":"#/$defs/identifier"}}, "filters":{"type":"array","minItems":1,"maxItems":10,"items":{"type":"object","additionalProperties":false,"required":["column","operator","value"],"properties":{ "column":{"$ref":"#/$defs/identifier"},"operator":{"enum":["eq","ne","lt","le","gt","ge","contains","in"]}, diff --git a/src/viano_bug_analysis/tools/context_expansion.py b/src/viano_bug_analysis/tools/context_expansion.py index 10fef43..14c52fe 100644 --- a/src/viano_bug_analysis/tools/context_expansion.py +++ b/src/viano_bug_analysis/tools/context_expansion.py @@ -10,22 +10,32 @@ from .config import ToolError class ContextExpansion: def __init__(self, service, settings): + from .sql_database import is_table_reference self.service = service self.rules = settings.get("relationships", []) self.text_rules = settings.get("texts", []) self.name_rules = [r for r in settings.get("entities", []) if "name_column" in r] for rule in self.name_rules: if (any(not isinstance(rule.get(k),str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,63}",rule[k]) - for k in ('entity_type','table','column','name_column')) + for k in ('entity_type','column','name_column')) + or not is_table_reference(rule.get('table')) or not isinstance(rule.get('columns'),list) or not {rule['column'],rule['name_column']} <= set(rule['columns'])): raise ToolError("PROJECT_ENRICHMENT_CONFIG") + self.max_queries = settings.get("max_queries", 40) self.max_depth = settings.get("max_depth", 2) self.max_relationships = settings.get("max_relationships", 128) if any(type(v) is not int or v <= 0 for v in (self.max_queries, self.max_depth, self.max_relationships)): raise ToolError("PROJECT_ENRICHMENT_CONFIG") + def same_table(self,first,second): + if first==second:return True + provider=self.service.database + if not callable(getattr(provider,'resolve_table',None)):return False + try:return provider.resolve_table(first)==provider.resolve_table(second) + except ToolError:return False + def collect(self, queries): queue = [(copy.deepcopy(q), 0) for q in queries] seen, edges, entities = set(), [], [] @@ -51,7 +61,7 @@ class ContextExpansion: names = [] if query["kind"] == "database.rows": for rule in self.name_rules: - if rule["table"] != query["table"]: + if not self.same_table(rule['table'],query['table']): continue for index, item in enumerate(capture["result"]["items"]): row = item.get("row", {}) @@ -68,7 +78,7 @@ class ContextExpansion: if query["kind"] == "database.rows": for rule in self.text_rules: - if rule["table"] != query["table"]: + if not self.same_table(rule['table'],query['table']): continue for i, item in enumerate(capture["result"]["items"]): row = item.get("row", {}) @@ -93,7 +103,7 @@ class ContextExpansion: if query["kind"] != "database.rows": continue for rule in self.rules: - if rule["from_table"] != query["table"]: + if not self.same_table(rule['from_table'],query['table']): continue for i, item in enumerate(capture["result"]["items"]): row = item.get("row", {}) diff --git a/src/viano_bug_analysis/tools/database_relations.py b/src/viano_bug_analysis/tools/database_relations.py index 99c0902..97e8bed 100644 --- a/src/viano_bug_analysis/tools/database_relations.py +++ b/src/viano_bug_analysis/tools/database_relations.py @@ -54,9 +54,10 @@ class DatabaseRelations: for index, node in enumerate(profile['nodes']): if not isinstance(node, dict) or set(node) - {'name', 'table', 'columns', 'match_column', 'parent', 'parent_column', 'group_by'}: raise ToolError('PROJECT_RELATIONS_CONFIG') - for key in ('name', 'table', 'match_column'): + for key in ('name', 'match_column'): identifier(node[key]) - if node['name'] in seen or node['table'] not in provider.tables: + provider.resolve_table(node['table']) + if node['name'] in seen: raise ToolError('PROJECT_RELATIONS_CONFIG') columns = node['columns'] if (not isinstance(columns, list) or not 1 <= len(columns) <= 30 @@ -100,13 +101,16 @@ class DatabaseRelations: cursor.execute('SET SESSION max_statement_time = %s', (provider.limits.timeout_seconds,)) cursor.execute('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ') cursor.execute('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY') - tables = sorted({n['table'] for n in profile['nodes']}) - cursor.execute('SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES ' - 'WHERE TABLE_SCHEMA=%s AND TABLE_NAME IN (' + ','.join('%s' for _ in tables) + ')', - (provider.database, *tables)) - engines = {r['TABLE_NAME']: r['ENGINE'] for r in cursor.fetchall()} - if set(engines) != set(tables) or any(str(e).lower() not in {'innodb', 'xtradb'} for e in engines.values()): - raise ToolError('PROJECT_RELATIONS_SNAPSHOT_UNSUPPORTED') + tables={provider.resolve_table(n['table']) for n in profile['nodes']} + # One connection and one read-only snapshot, also across schemas. + for database in sorted({db for db,_ in tables}): + names=sorted(table for db,table in tables if db==database) + cursor.execute('SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES ' + 'WHERE TABLE_SCHEMA=%s AND TABLE_NAME IN (' + ','.join('%s' for _ in names) + ')', + (database,*names)) + engines = {r['TABLE_NAME']: r['ENGINE'] for r in cursor.fetchall()} + if set(engines)!=set(names) or any(str(e).lower() not in {'innodb','xtradb'} for e in engines.values()): + raise ToolError('PROJECT_RELATIONS_SNAPSHOT_UNSUPPORTED') # Schema and rows are read through the same transaction/connection. for node in profile['nodes']: if time.monotonic() >= deadline: @@ -146,6 +150,8 @@ class DatabaseRelations: 'returned_rows': len(rows), 'local_truncated': result['truncated'], 'upstream_complete': inherited, 'complete': complete, 'coverage': 'complete_configured_relation' if complete else 'partial_configured_relation'} + if provider.additional_databases or '.' in node['table']: + item['database']=provider.resolve_table(node['table'])[0] if node.get('group_by'): counts = {} for row in rows: diff --git a/src/viano_bug_analysis/tools/project_query.py b/src/viano_bug_analysis/tools/project_query.py index f5e7e91..8942930 100644 --- a/src/viano_bug_analysis/tools/project_query.py +++ b/src/viano_bug_analysis/tools/project_query.py @@ -31,8 +31,8 @@ class ProjectLimits: class ProjectResearch: """Provider-independent boundary used by case enrichment and investigation. - Configurations are trusted operator inputs; case/model inputs cannot select - a URL, directory, credentials, database, branch, or arbitrary SQL. + Configurations are trusted operator inputs. Models can select only configured + database/table references, never URLs, directories, credentials or arbitrary SQL. """ def __init__(self, project, *, source=None, database=None, limits=None, relations=None, source_analysis=None): @@ -131,7 +131,7 @@ class ProjectResearch: def collect(self, arguments, *, source_context=None): self.validate(arguments) provider = self.database if arguments["kind"].startswith("database.") else self.source - binding = provider.binding() + binding = provider.binding_for(arguments) if callable(getattr(provider,'binding_for',None)) else provider.binding() if arguments['kind'] == 'database.relations': binding = {**binding, 'relation_profile': arguments['profile'], 'consistency': 'single_repeatable_read_read_only_transaction', diff --git a/src/viano_bug_analysis/tools/sql_database.py b/src/viano_bug_analysis/tools/sql_database.py index f821edf..ef36a65 100644 --- a/src/viano_bug_analysis/tools/sql_database.py +++ b/src/viano_bug_analysis/tools/sql_database.py @@ -16,6 +16,10 @@ def identifier(value): return "`" + value + "`" +def is_table_reference(value): + return isinstance(value,str) and re.fullmatch(r'(?:[A-Za-z_][A-Za-z0-9_]{0,63}\.)?[A-Za-z_][A-Za-z0-9_]{0,63}',value) is not None + + def scalar(value): if isinstance(value, decimal.Decimal): return str(value) # retain exact SQL decimal precision @@ -30,25 +34,88 @@ def scalar(value): class SQLDatabaseProvider: def __init__(self, host, port, database, user, snapshot_id, - tables, *, password_env=None, password_file=None, limits=None, connection_factory=None): + tables, *, password_env=None, password_file=None, limits=None, connection_factory=None, + additional_databases=None): self.limits = limits or ProjectLimits() if (not isinstance(host, str) or type(port) is not int or not 0 < port < 65536 or not user or bool(password_env) == bool(password_file) or not snapshot_id or not tables): raise ToolError("PROJECT_DATABASE_CONFIG") identifier(database) - for table in tables: - identifier(table) + def allowlist(value): + if (not isinstance(value,(list,tuple)) or not value or any(not isinstance(t,str) for t in value) + or len(set(value))!=len(value)): + raise ToolError('PROJECT_DATABASE_CONFIG') + if list(value)!=['*']: + for table in value:identifier(table) + return tuple(value) + default_tables=allowlist(tables) + if additional_databases is not None and not isinstance(additional_databases,dict): + raise ToolError('PROJECT_DATABASE_CONFIG') + self.additional_databases={} + for name,allowed in (additional_databases or {}).items(): + identifier(name) + if name==database:raise ToolError('PROJECT_DATABASE_CONFIG') + self.additional_databases[name]=allowlist(allowed) + self.table_allowlists={database:default_tables,**self.additional_databases} self.host, self.port, self.database = host, port, database self.user, self.password_env, self.snapshot_id = user, password_env, snapshot_id self.password_file = Path(password_file) if password_file else None - self.tables = tuple(tables) + self.tables = default_tables self.connection_factory = connection_factory self._schemas = {} def binding(self): # No credentials or server address are written into portable case packages. - return {"kind":"mariadb", "database":self.database, "snapshot_id":self.snapshot_id, + result={"kind":"mariadb", "database":self.database, "snapshot_id":self.snapshot_id, "tables":sorted(self.tables), "time_scope":"configured_snapshot_not_current_production"} + if self.additional_databases: + result['additional_databases']={name:sorted(tables) for name,tables in sorted(self.additional_databases.items())} + result['table_reference']='database.table; unqualified tables use the default database only' + return result + + def binding_for(self,args): + result=self.binding() + if args.get('table'): + database,table=self.resolve_table(args['table']) + if self.additional_databases or '.' in args['table']: + result.update(selected_database=database,selected_table=table) + return result + + def resolve_table(self,reference): + if not is_table_reference(reference):raise ToolError('PROJECT_DATABASE_IDENTIFIER') + parts=reference.split('.') + database,table=(self.database,parts[0]) if len(parts)==1 else parts + allowed=self.table_allowlists.get(database) + if allowed is None:raise ToolError('PROJECT_DATABASE_FORBIDDEN') + if table not in allowed and allowed!=('*',):raise ToolError('PROJECT_DATABASE_TABLE_FORBIDDEN') + return database,table + + def _table_inventory(self): + result=[] + for database,allowed in sorted(self.table_allowlists.items()): + if allowed==('*',): + connection=self._connect() + try: + with connection.cursor() as cursor: + cursor.execute('SET SESSION max_statement_time = %s',(self.limits.timeout_seconds,)) + cursor.execute('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=%s ORDER BY TABLE_NAME LIMIT %s', + (database,self.limits.max_files+1)) + names=[row['TABLE_NAME'] for row in cursor.fetchall()] + if not names:raise ToolError('PROJECT_DATABASE_TABLE_UNKNOWN') + if len(names)>self.limits.max_files:raise ToolError('PROJECT_DATABASE_TABLE_LIMIT') + except ToolError:raise + except Exception:raise ToolError('PROJECT_DATABASE_QUERY_FAILED') from None + finally:connection.close() + else:names=allowed + for table in sorted(names): + if not is_table_reference(table) or '.' in table:raise ToolError('PROJECT_DATABASE_IDENTIFIER') + reference=f'{database}.{table}' if self.additional_databases else table + result.append({'database':database,'table':reference}) + return result + + @property + def schema_targets(self): + return tuple(row['table'] for row in self._table_inventory()) def _connect(self): if self.connection_factory: @@ -75,8 +142,7 @@ class SQLDatabaseProvider: def validate(self, args): if args["kind"] == "database.tables": return - if args["table"] not in self.tables: - raise ToolError("PROJECT_DATABASE_TABLE_FORBIDDEN") + self.resolve_table(args['table']) for clause in args.get("filters", []): if ((clause["operator"] == "in") != isinstance(clause["value"], list) or (clause["operator"] == "contains" and not isinstance(clause["value"], str)) @@ -91,12 +157,13 @@ class SQLDatabaseProvider: def _schema(self, table): """Bounded metadata preflight; does not return table data to the model.""" if table not in self._schemas: + database,name=self.resolve_table(table) connection = self._connect() try: with connection.cursor() as cursor: cursor.execute("SET SESSION max_statement_time = %s", (self.limits.timeout_seconds,)) cursor.execute("SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY FROM information_schema.COLUMNS " - "WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s ORDER BY ORDINAL_POSITION", (self.database,table)) + "WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s ORDER BY ORDINAL_POSITION", (database,name)) schema = cursor.fetchall() if not schema: raise ToolError("PROJECT_DATABASE_TABLE_UNKNOWN") @@ -132,11 +199,14 @@ class SQLDatabaseProvider: def query(self, args): self.validate(args) if args["kind"] == "database.tables": - names = sorted(t for t in self.tables if args["text"].casefold() in t.casefold()) + names = [row for row in self._table_inventory() if args['text'].casefold() in row['table'].casefold()] page = names[args["offset"]:args["offset"]+args["limit"]] truncated = len(names) > args["offset"]+len(page) - return {"items":[{"table":t} for t in page], "coverage":"configured_table_allowlist", + result={"items":page if self.additional_databases or self.tables==('*',) else [{'table':row['table']} for row in page], "coverage":"configured_table_allowlist", "truncated":truncated, "next_offset":args["offset"]+len(page) if truncated else None} + if any(tables==('*',) for tables in self.table_allowlists.values()): + result['interpretation']='Configured databases only; wildcard catalogs include metadata-visible tables. Database grants may hide other tables.' + return result connection = self._connect() try: with connection.cursor() as cursor: @@ -156,25 +226,27 @@ class SQLDatabaseProvider: def _query_cursor(self, cursor, args, *, schema=None): # Metadata is constrained by the configured database/table, not arbitrary SQL. + database,name=self.resolve_table(args['table']) + scope={'database':database} if self.additional_databases or '.' in args['table'] else {} if schema is None: cursor.execute("SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY FROM information_schema.COLUMNS " "WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s ORDER BY ORDINAL_POSITION", - (self.database, args["table"])) + (database, name)) schema = cursor.fetchall() names = [c["COLUMN_NAME"] for c in schema] if not names: raise ToolError("PROJECT_DATABASE_TABLE_UNKNOWN") if args["kind"] == "database.schema": - return {"items":[{"table":args["table"], "columns":schema}], "coverage":"schema_only", "truncated":False} + return {"items":[{"table":args["table"], "columns":schema,**scope}], "coverage":"schema_only", "truncated":False} requested = set(args["columns"]) | {f["column"] for f in args["filters"]} if not requested <= set(names): raise ToolError("PROJECT_DATABASE_COLUMN_UNKNOWN") columns = args["columns"] or names[:30] where, values = self.compile_filters(args["filters"]) - table = identifier(args["table"]) + table = identifier(database)+'.'+identifier(name) if '.' in args['table'] else identifier(name) if args["kind"] == "database.count": cursor.execute("SELECT COUNT(*) AS matched_rows FROM " + table + " WHERE " + where, values) - return {"items":cursor.fetchall(), "coverage":"complete_filtered_count", "truncated":False} + return {"items":[{**row,**scope} for row in cursor.fetchall()], "coverage":"complete_filtered_count", "truncated":False} # Deterministic order uses unique key when possible, otherwise all selected columns. order = [c["COLUMN_NAME"] for c in schema if c["COLUMN_KEY"] == "PRI"] or columns sql = ("SELECT " + ",".join(identifier(c) for c in columns) + " FROM " + table + " WHERE " + where @@ -182,6 +254,6 @@ class SQLDatabaseProvider: cursor.execute(sql, (*values, args["limit"]+1, args["offset"])) rows = cursor.fetchall() truncated = len(rows) > args["limit"] - return {"items":[{"table":args["table"], "row":{k:scalar(v) for k,v in row.items()}} for row in rows[:args["limit"]]], + return {"items":[{"table":args["table"],**scope, "row":{k:scalar(v) for k,v in row.items()}} for row in rows[:args["limit"]]], "coverage":"partial_filtered_rows" if truncated or args["offset"] else "complete_filtered_rows", "truncated":truncated, "next_offset":args["offset"]+args["limit"] if truncated else None} diff --git a/src/viano_bug_analysis/web/user-manual.json b/src/viano_bug_analysis/web/user-manual.json index e4a5dd5..5f2a7f0 100644 --- a/src/viano_bug_analysis/web/user-manual.json +++ b/src/viano_bug_analysis/web/user-manual.json @@ -14,7 +14,7 @@ { "id": "project-configuration", "title": "Projekt-, Source-, DB- und Modellkonfiguration", - "markdown": "# Projekt und Recherchezugriffe konfigurieren\n\nDie Projektkonfiguration wird über Dateien verwaltet, nicht über die Webseite.\nSie legt fest, welche Werkzeuge ein Auftrag verwenden darf. Ein aufrufendes\nProgramm kann weder Repository noch DB-Zugänge oder Budgets mit einem Auftrag\nüberschreiben. Aktuell ist das Profil `wow/vcore` mit dem Importadapter `vcore`\nimplementiert; andere Projekttypen erfordern entsprechende Adapter und Profile.\n\n## Zuordnung in api.toml\n\nEin Projektelement ordnet eine feste Projekt-ID einem Profil und dessen\nKonfigurationsdateien zu. Relative Pfade beziehen sich auf die API-Konfigurationsdatei.\nDer Erfassungsworker muss genau dieselbe Projekt-/Profilzuordnung senden und\nsein Integrationsschlüssel muss für das Projekt freigegeben sein.\n\n```toml\n[[projects]]\nid = \"viano-core\"\ntype = \"wow\"\nprofile = \"vcore\"\nadapter = \"vcore\"\nplanning_mode = \"joint\"\n[projects.settings]\nproject_config = \"project.toml\"\nllm_config = \"llm.toml\"\ninvestigation_config = \"investigation.bounded.toml\"\nbudget_config = \"budget.human-tests.toml\"\n```\n\nDie Linux-Vorlagen stehen unter `deploy/linux/`; der Installer legt die\nKonfiguration unter `/etc/viano-bug-analysis` an. Er überschreibt bestehende\nDateien nicht. Die ausführliche Vcore-Projektvorlage steht unter\n`config/project.example.toml`, die Relationsregeln unter\n`config/project-relations.vcore.toml`.\n\n## Source und Datenbank in project.toml\n\n```toml\nrelations_file = \"project-relations.vcore.toml\"\n[project]\ntype = \"wow\"\nprofile = \"vcore\"\n\n[source]\npath = \"/srv/viano-bug-analysis/viano-core\"\nurl = \"https://gitea.viano-software.de/Nico/Viano-Core\"\ncommit = \"VOLLSTAENDIGEN_40_STELLIGEN_COMMIT_EINTRAGEN\"\n\n[database]\nhost = \"127.0.0.1\"\nport = 3306\ndatabase = \"cata_world\"\nuser = \"viano_reader\"\npassword_env = \"VIANO_DB_PASSWORD\"\nsnapshot_id = \"KENNUNG_DER_TATSAECHLICHEN_DATENGRUNDLAGE\"\ntables = [\"creature_template\", \"creature\"]\n```\n\nDas Beispiel zeigt nur den Aufbau und enthält absichtlich ungültige Platzhalter.\nDie beiden Tabellen sind keine vollständige Vcore-Freigabeliste. Für das echte\nProfil die vollständige Vorlage einschließlich Limits, Enrichment und\nAnalysewerkzeugregeln verwenden und passend prüfen.\n\nDer Source-Checkout muss lokal vorhanden sein und den konfigurierten Commit\nenthalten. Die URL allein klont oder aktualisiert kein Repository. Der Worker\nliest Git-Objekte dieses festen Stands und benötigt keine Schreibrechte auf den\nCheckout. Die Linux-Vorlage enthält eine gezielte Git-Vertrauenskonfiguration\nfür den root-eigenen Checkout. Neue Source-Stände bewusst bereitstellen und\nderen vollständigen Commit konfigurieren.\n\nDer DB-Administrator legt einen Benutzer mit ausschließlich passenden SELECT-\nRechten an. Zusätzlich begrenzt `tables` die zugänglichen Tabellen. Diese\nFreigabeliste ersetzt keine serverseitigen Rechte. Account- oder Charakterdaten\ngehören nicht zur standardmäßigen World-Recherche. Für entfernte DBs einen\ngeschützten Netzwerkweg nutzen; der aktuelle Provider konfiguriert selbst kein TLS.\n\nDas Passwort steht entweder in der durch `password_env` benannten\nUmgebungsvariable oder einer separaten geschützten Datei über `password_file`.\nNur eine dieser Varianten setzen. Unter Linux lädt der Worker beispielsweise\n`VIANO_DB_PASSWORD` aus seiner root-geschützten `worker.env`.\nPasswörter nicht in Fallakten, LLM-Prompts oder Versionsverwaltung ablegen.\nDie LLM bekommt Werkzeugergebnisse statt Zugangsdaten.\n\n## Weitere Konfigurationen\n\n| Datei | Zuständigkeit |\n| --- | --- |\n| api.toml | HTTP-Bindung, Workspace, Projekte, Nutzerdatei und optionale env-basierte Maschinenclients |\n| worker.toml | Verweis auf api.toml, Polling, Wiederholungsabstände, Speicherreserve und Telemetrie |\n| llm.toml | Anbieter, Modell, Schlüssel-Environment, Timeouts, Ausgabe-/Denkeinstellungen und Cache |\n| investigation.bounded.toml | Untersuchungsmodus und begrenztes Kontextfenster |\n| budget.human-tests.toml | Fachliche Budgetregeln und menschliche Testanfragen |\n| telemetry.toml | Heartbeats und lokale Betriebsbeobachtung |\n| project-relations.vcore.toml | Projektspezifische Beziehungen für Recherchewerkzeuge |\n\nDie versionierte Modellvorlage `config/llm.gemini-cache.example.toml` verwendet\n`GEMINI_API_KEY`. Das ist unabhängig vom Integrationsschlüssel für externe\nAuftragsübergaben. Die API erhält `api.env`; der Analyseworker erhält\n`worker.env` mit Cloud- und DB-Zugangsdaten.\n\n## Prüfen und ändern\n\nDer Befehl `python -m viano_bug_analysis.deployment check` prüft mit Rolle und\nKonfigurationspfad die Betriebsbereitschaft ohne Modellanfrage. Die\n[Linux-Anleitung](linux-deployment.md) zeigt die vollständigen Befehle unter der\ntatsächlichen Dienstidentität. Die Prüfung umfasst Git-Objekte und freigegebene\nDB-Schemata; die Gültigkeit des Cloud-Schlüssels wird nicht durch einen bezahlten\nModellaufruf getestet. `--offline` prüft keine Git-/DB-Verbindung.\n\nVor Code- oder Profiländerungen den Worker kontrolliert anhalten und die\nAuswirkungen auf laufende Akten prüfen. Der Worker bindet Fälle an ihren\nKonfigurationsstand. Eine Änderung ist kein Weg zum Zurücksetzen von Budgets;\nabweichende Bindungen können eine Betreiberprüfung erfordern. Bestehende Dateien\nund Zugangsdaten vor Änderungen sichern.\n" + "markdown": "# Projekt und Recherchezugriffe konfigurieren\n\nDie Projektkonfiguration wird über Dateien verwaltet, nicht über die Webseite.\nSie legt fest, welche Werkzeuge ein Auftrag verwenden darf. Ein aufrufendes\nProgramm kann weder Repository noch DB-Zugänge oder Budgets mit einem Auftrag\nüberschreiben. Aktuell ist das Profil `wow/vcore` mit dem Importadapter `vcore`\nimplementiert; andere Projekttypen erfordern entsprechende Adapter und Profile.\n\n## Zuordnung in api.toml\n\nEin Projektelement ordnet eine feste Projekt-ID einem Profil und dessen\nKonfigurationsdateien zu. Relative Pfade beziehen sich auf die API-Konfigurationsdatei.\nDer Erfassungsworker muss genau dieselbe Projekt-/Profilzuordnung senden und\nsein Integrationsschlüssel muss für das Projekt freigegeben sein.\n\n```toml\n[[projects]]\nid = \"viano-core\"\ntype = \"wow\"\nprofile = \"vcore\"\nadapter = \"vcore\"\nplanning_mode = \"joint\"\n[projects.settings]\nproject_config = \"project.toml\"\nllm_config = \"llm.toml\"\ninvestigation_config = \"investigation.bounded.toml\"\nbudget_config = \"budget.human-tests.toml\"\n```\n\nDie Linux-Vorlagen stehen unter `deploy/linux/`; der Installer legt die\nKonfiguration unter `/etc/viano-bug-analysis` an. Er überschreibt bestehende\nDateien nicht. Die ausführliche Vcore-Projektvorlage steht unter\n`config/project.example.toml`, die Relationsregeln unter\n`config/project-relations.vcore.toml`.\n\n## Source und Datenbank in project.toml\n\n```toml\nrelations_file = \"project-relations.vcore.toml\"\n[project]\ntype = \"wow\"\nprofile = \"vcore\"\n\n[source]\npath = \"/srv/viano-bug-analysis/viano-core\"\nurl = \"https://gitea.viano-software.de/Nico/Viano-Core\"\ncommit = \"VOLLSTAENDIGEN_40_STELLIGEN_COMMIT_EINTRAGEN\"\n\n[database]\nhost = \"127.0.0.1\"\nport = 3306\ndatabase = \"cata_world\"\nuser = \"viano_reader\"\npassword_env = \"VIANO_DB_PASSWORD\"\nsnapshot_id = \"KENNUNG_DER_TATSAECHLICHEN_DATENGRUNDLAGE\"\ntables = [\"creature_template\", \"creature\"]\n```\n\nDas Beispiel zeigt nur den Aufbau und enthält absichtlich ungültige Platzhalter.\nDie beiden Tabellen sind keine vollständige Vcore-Freigabeliste. Für das echte\nProfil die vollständige Vorlage einschließlich Limits, Enrichment und\nAnalysewerkzeugregeln verwenden und passend prüfen.\n\nDer Source-Checkout muss lokal vorhanden sein und den konfigurierten Commit\nenthalten. Die URL allein klont oder aktualisiert kein Repository. Der Worker\nliest Git-Objekte dieses festen Stands und benötigt keine Schreibrechte auf den\nCheckout. Die Linux-Vorlage enthält eine gezielte Git-Vertrauenskonfiguration\nfür den root-eigenen Checkout. Neue Source-Stände bewusst bereitstellen und\nderen vollständigen Commit konfigurieren.\n\nDer DB-Administrator legt einen Benutzer mit ausschließlich passenden SELECT-\nRechten an. Zusätzlich begrenzt `tables` die zugänglichen Tabellen. Diese\nFreigabeliste ersetzt keine serverseitigen Rechte. Account- oder Charakterdaten\ngehören nicht zur standardmäßigen World-Recherche. Für entfernte DBs einen\ngeschützten Netzwerkweg nutzen; der aktuelle Provider konfiguriert selbst kein TLS.\n\nDas Passwort steht entweder in der durch `password_env` benannten\nUmgebungsvariable oder einer separaten geschützten Datei über `password_file`.\nNur eine dieser Varianten setzen. Unter Linux lädt der Worker beispielsweise\n`VIANO_DB_PASSWORD` aus seiner root-geschützten `worker.env`.\nPasswörter nicht in Fallakten, LLM-Prompts oder Versionsverwaltung ablegen.\nDie LLM bekommt Werkzeugergebnisse statt Zugangsdaten.\n\n## Mehrere Datenbanken pro Projekt\n\nEine Projektverbindung kann mehrere Datenbanken derselben MariaDB-Instanz\nansprechen. Host, Port, DB-Benutzer und Passwortbezug werden gemeinsam verwendet.\n`database` benennt die Standarddatenbank; unqualifizierte Tabellennamen beziehen\nsich ausschließlich auf diese. Zusätzliche Datenbanken werden ausdrücklich\nfreigegeben, etwa für Vcore:\n\n```toml\n# Im vorhandenen [database]-Abschnitt: database = \"cata_world\", user = \"buganalysis\"\n[database.additional_databases]\ncata_hotfixes = [\"*\"]\ncata_locale = [\"*\"]\n```\n\nDen neuen Abschnitt beispielsweise am Ende der vorhandenen Datei ergänzen.\n`[database]` nicht ein zweites Mal anlegen und die übrigen Projektregeln behalten.\nDie vollständige alternative Vorlage steht unter\n`config/project.vcore-multidb.example.toml`.\n\n`[\"*\"]` gibt ausdrücklich alle metadaten-sichtbaren Tabellen der genannten DB\nfrei. Alternativ eine nichtleere Liste konkreter Tabellen verwenden. Auch die\nStandarddatenbank unterstützt `tables = [\"*\"]`; die bisherige konkrete Liste\nbleibt genauso gültig. Ein leeres Array ist keine pauschale Freigabe.\nDie tatsächlich gewährten DB-Rechte bleiben zusätzlich maßgeblich.\nNicht aufgeführte Datenbanken sind selbst bei weitergehenden DB-Grants gesperrt.\nIn diesem Beispiel sind `cata_auth`, `cata_characters` und\n`cata_locale_reference` nicht für Recherche freigegeben.\n\nWerkzeuge verwenden in zusätzlichen DBs qualifizierte Tabellennamen wie\n`cata_locale.NAME_DER_TABELLE`. `database.tables` entdeckt die zugänglichen Namen\nüber alle freigegebenen DBs; Suchtext und Seitengrenzen gelten weiterhin.\nGleichnamige Tabellen werden nicht verwechselt. Die Datenbank steht in der\nBelegherkunft und bei Mehrdatenbank-Ergebnissen auch in den Ergebniseinträgen.\nDie LLM erhält keine Serveradresse, Anmeldenamen oder Passwörter.\n\nNamenskataloge, Enrichment-Regeln und Beziehungsprofile können ebenfalls\nqualifizierte Tabellen referenzieren. Die Beziehungsanalyse führt die\nkonfigurierten Schritte innerhalb einer gemeinsamen lesenden Transaktion aus;\nsie prüft die Transaktionsfähigkeit der beteiligten Tabellen. Dieses Format\nkonfiguriert keine unterschiedlichen DB-Server oder getrennten Zugangsdaten\ninnerhalb derselben Projektverbindung.\n\nDie Bereitschaftsprüfung prüft alle freigegebenen Tabellen einschließlich\nWildcard-Inventaren. Eine nicht erreichbare oder nicht lesbare zusätzliche DB\nwird nicht stillschweigend ausgelassen. Bestehende Einzel-DB-Konfigurationen\nbenötigen keine Änderung. Bei aktiven Fallakten trotzdem die bestehende\nKonfigurationsbindung beachten; ein Update setzt keine Budgets zurück.\n\n## Weitere Konfigurationen\n\n| Datei | Zuständigkeit |\n| --- | --- |\n| api.toml | HTTP-Bindung, Workspace, Projekte, Nutzerdatei und optionale env-basierte Maschinenclients |\n| worker.toml | Verweis auf api.toml, Polling, Wiederholungsabstände, Speicherreserve und Telemetrie |\n| llm.toml | Anbieter, Modell, Schlüssel-Environment, Timeouts, Ausgabe-/Denkeinstellungen und Cache |\n| investigation.bounded.toml | Untersuchungsmodus und begrenztes Kontextfenster |\n| budget.human-tests.toml | Fachliche Budgetregeln und menschliche Testanfragen |\n| telemetry.toml | Heartbeats und lokale Betriebsbeobachtung |\n| project-relations.vcore.toml | Projektspezifische Beziehungen für Recherchewerkzeuge |\n\nDie versionierte Modellvorlage `config/llm.gemini-cache.example.toml` verwendet\n`GEMINI_API_KEY`. Das ist unabhängig vom Integrationsschlüssel für externe\nAuftragsübergaben. Die API erhält `api.env`; der Analyseworker erhält\n`worker.env` mit Cloud- und DB-Zugangsdaten.\n\n## Prüfen und ändern\n\nDer Befehl `python -m viano_bug_analysis.deployment check` prüft mit Rolle und\nKonfigurationspfad die Betriebsbereitschaft ohne Modellanfrage. Die\n[Linux-Anleitung](linux-deployment.md) zeigt die vollständigen Befehle unter der\ntatsächlichen Dienstidentität. Die Prüfung umfasst Git-Objekte und freigegebene\nDB-Schemata; die Gültigkeit des Cloud-Schlüssels wird nicht durch einen bezahlten\nModellaufruf getestet. `--offline` prüft keine Git-/DB-Verbindung.\n\nVor Code- oder Profiländerungen den Worker kontrolliert anhalten und die\nAuswirkungen auf laufende Akten prüfen. Der Worker bindet Fälle an ihren\nKonfigurationsstand. Eine Änderung ist kein Weg zum Zurücksetzen von Budgets;\nabweichende Bindungen können eine Betreiberprüfung erfordern. Bestehende Dateien\nund Zugangsdaten vor Änderungen sichern.\n" }, { "id": "intake-api", diff --git a/tests/test_multidatabase_research.py b/tests/test_multidatabase_research.py new file mode 100644 index 0000000..4f7d1c7 --- /dev/null +++ b/tests/test_multidatabase_research.py @@ -0,0 +1,190 @@ +import copy +import json +import sqlite3 + +import pytest + +from viano_bug_analysis.tools.config import ToolError +from viano_bug_analysis.tools.sql_database import SQLDatabaseProvider +from viano_bug_analysis.tools.project_query import ProjectResearch +from viano_bug_analysis.tools.context_expansion import ContextExpansion +from viano_bug_analysis.analysis.entity_resolution import policy +from test_case_builder import case_setup +from test_investigation_tools import tool_case,batch + + +class Database: + """Real attached-schema SQL execution; only MariaDB metadata/transaction syntax simulated.""" + def __init__(self): + self.db=sqlite3.connect(':memory:');self.db.row_factory=sqlite3.Row + self.names={'cata_world':['switches'],'cata_hotfixes':['switches'],'cata_locale':['texts'],'cata_auth':['accounts']} + for name,tables in self.names.items(): + self.db.execute(f"ATTACH DATABASE ':memory:' AS {name}") + for table in tables: + self.db.execute(f'CREATE TABLE {name}.{table}(id INTEGER PRIMARY KEY,name TEXT)') + self.db.execute(f'INSERT INTO {name}.{table} VALUES (1,?)',(name,)) + self.calls=[];self.connections=0;self.closed=0;self.rollbacks=0;self.engine='InnoDB' + def connect(self):self.connections+=1;return self + def close(self):self.closed+=1 + def rollback(self):self.rollbacks+=1 + def cursor(self):return self + def __enter__(self):return self + def __exit__(self,*args):pass + def fetchall(self):return copy.deepcopy(self.result) + def execute(self,sql,args=()): + self.calls.append((sql,args)) + if sql.startswith(('SET ','START ')):self.result=[];return + if 'information_schema.COLUMNS' in sql: + self.result=[{'COLUMN_NAME':row['name'],'COLUMN_TYPE':'int' if row['type']=='INTEGER' else 'text', + 'IS_NULLABLE':'YES','COLUMN_KEY':'PRI' if row['pk'] else ''} + for row in self.db.execute(f'PRAGMA {args[0]}.table_info(`{args[1]}`)')];return + if 'information_schema.TABLES' in sql: + names=self.names.get(args[0],[]) + if 'ENGINE' in sql:self.result=[{'TABLE_NAME':name,'ENGINE':self.engine} for name in names if name in args[1:]] + else:self.result=[{'TABLE_NAME':name} for name in sorted(names)[:args[1]]] + return + self.result=[dict(row) for row in self.db.execute(sql.replace('%s','?'),args)] + + +def setup(additional=None): + db=Database() + provider=SQLDatabaseProvider('127.0.0.1',3306,'cata_world','buganalysis','snapshot',['switches'], + password_env='SECRET_NOT_READ',connection_factory=db.connect, + additional_databases=additional if additional is not None else {'cata_hotfixes':['switches'],'cata_locale':['*']}) + return ProjectResearch({},database=provider),db + + +def rows(table):return {'kind':'database.rows','table':table,'columns':['id','name'], + 'filters':[{'column':'id','operator':'eq','value':1}],'offset':0,'limit':10} + + +def test_same_named_tables_have_independent_schemas_and_provenance(): + service,db=setup() + world=json.loads(service.collect(rows('switches'))) + hotfix=json.loads(service.collect(rows('cata_hotfixes.switches'))) + locale=json.loads(service.collect(rows('cata_locale.texts'))) + assert world['result']['items'][0]['row']['name']=='cata_world' + assert hotfix['result']['items'][0]['row']['name']=='cata_hotfixes' + assert locale['result']['items'][0]['row']['name']=='cata_locale' + assert world['origin']['selected_database']=='cata_world' + assert hotfix['origin']['selected_database']=='cata_hotfixes' + assert 'buganalysis' not in str(hotfix['origin']) and '127.0.0.1' not in str(hotfix['origin']) + assert any('FROM `cata_hotfixes`.`switches`' in sql for sql,_ in db.calls) + assert {args[0] for sql,args in db.calls if 'information_schema.COLUMNS' in sql}=={'cata_world','cata_hotfixes','cata_locale'} + + +@pytest.mark.parametrize('table',['cata_auth.accounts','cata_characters.characters','cata_locale_reference.texts', + 'cata_hotfixes.forbidden','cata_locale.texts.extra','cata_locale.texts;DROP TABLE x']) +def test_unconfigured_databases_tables_and_injection_blocked_before_access(table): + service,db=setup() + with pytest.raises(ToolError):service.collect(rows(table)) + assert db.connections==0 + + +def test_catalog_and_readiness_expand_only_explicitly_allowed_schemas(): + service,db=setup() + result=json.loads(service.collect({'kind':'database.tables','text':'','offset':0,'limit':10}))['result'] + assert [r['table'] for r in result['items']]==['cata_hotfixes.switches','cata_locale.texts','cata_world.switches'] + assert service.database.schema_targets==tuple(r['table'] for r in result['items']) + assert {args[0] for sql,args in db.calls if 'information_schema.TABLES' in sql}=={'cata_locale'} + page=json.loads(service.collect({'kind':'database.tables','text':'','offset':1,'limit':1}))['result'] + assert page['items'][0]['table']=='cata_locale.texts' and page['truncated'] and page['next_offset']==2 + + +def test_cross_database_relations_use_one_read_only_repeatable_snapshot(): + service,db=setup() + from viano_bug_analysis.tools.database_relations import DatabaseRelations + profile={'inspect':{'description':'Linked schema records','nodes':[ + {'name':'root','table':'switches','match_column':'id','columns':['id','name']}, + {'name':'child','table':'cata_hotfixes.switches','match_column':'id','columns':['id','name'], + 'parent':'root','parent_column':'id'}, + {'name':'locale','table':'cata_locale.texts','match_column':'id','columns':['id','name'], + 'parent':'child','parent_column':'id'}]}} + service.relations=DatabaseRelations(service.database,profile) + capture=json.loads(service.collect({'kind':'database.relations','profile':'inspect','value':1})) + assert [n['database'] for n in capture['result']['items']]==['cata_world','cata_hotfixes','cata_locale'] + assert capture['result']['coverage']=='complete_configured_relations' + assert db.connections==db.closed==db.rollbacks==1 + assert sum(sql.startswith('START TRANSACTION') for sql,_ in db.calls)==1 + assert capture['origin']['consistency']=='single_repeatable_read_read_only_transaction' + + +def test_entity_resolution_and_context_rules_allow_qualified_tables(): + catalog={'id':'names','entity_type':'creature','table':'cata_locale.texts','id_column':'id','name_column':'name','columns':['id','name']} + assert policy({'enabled':True,'catalogs':[catalog]})['catalogs'][0]['table']=='cata_locale.texts' + service,_=setup() + expansion=ContextExpansion(service,{'entities':[{'entity_type':'creature','table':'texts', + 'column':'id','name_column':'name','columns':['id','name']}]}) + assert expansion.same_table('switches','cata_world.switches') + assert not expansion.same_table('switches','cata_hotfixes.switches') + expansion=ContextExpansion(service,{'entities':[{'entity_type':'creature','table':'cata_locale.texts', + 'column':'id','name_column':'name','columns':['id','name']}]}) + _,facts,_,_=expansion.collect([rows('cata_locale.texts')]) + assert any(f['name']=='project_entity_name' and f['value']['name']=='cata_locale' for f in facts) + + +def test_config_loading_and_legacy_binding(tmp_path): + path=tmp_path/'project.toml' + path.write_text('''[project] +type="wow" +profile="vcore" +[database] +host="127.0.0.1" +port=3306 +database="cata_world" +user="buganalysis" +password_env="VIANO_DB_PASSWORD" +snapshot_id="test" +tables=["switches"] +[database.additional_databases] +cata_hotfixes=["switches"] +cata_locale=["*"] +''',encoding='utf-8') + service=ProjectResearch.load(path) + assert service.database.resolve_table('cata_locale.texts')==('cata_locale','texts') + with pytest.raises(ToolError):service.database.resolve_table('cata_auth.accounts') + legacy,_=setup({}) + assert legacy.database.binding()=={'kind':'mariadb','database':'cata_world','snapshot_id':'snapshot', + 'tables':['switches'],'time_scope':'configured_snapshot_not_current_production'} + assert legacy.database.binding_for(rows('switches'))==legacy.database.binding() + + +def test_multi_database_evidence_survives_package_validation(tool_case): + from viano_bug_analysis.package import PackageReader,PackageValidator + from viano_bug_analysis.tools import InvestigationToolRouter + from viano_bug_analysis.budget import BudgetManager + from viano_bug_analysis.evidence import EvidenceStore + store=tool_case[0];service,_=setup() + with PackageReader(tool_case[1]) as reader:service.project=reader.manifest['project'] + router=InvestigationToolRouter(project_research=service) + budget=BudgetManager(store);budget.initialize('case-A','stable-package') + reserved=budget.reserve('case-A','stable-package','H1','A1','project.query') + request=batch('project.query',rows('cata_hotfixes.switches')) + reply=router.query(reserved.package,request) + saved=EvidenceStore(store,router=router).store_tool_results('case-A','stable-package','multi-db',request,reply) + assert PackageValidator().validate(saved.package).valid + assert reply['results'][0]['data']['origin']['selected_database']=='cata_hotfixes' + + +def test_worker_readiness_checks_all_databases_and_rejects_missing_one(tmp_path,monkeypatch): + from types import SimpleNamespace + from viano_bug_analysis import deployment + from viano_bug_analysis.api import APIConfig,Project + service,db=setup();service.source=SimpleNamespace(inventory=lambda:{}) + config=APIConfig(tmp_path,(Project('p','wow','vcore'),),()) + monkeypatch.setattr(deployment.WorkerConfig,'load',lambda _:SimpleNamespace(intake=config,min_free_bytes=1)) + loader=lambda _:(object(),{'project_research':service},'hash') + assert deployment.check('worker','unused',loader=loader)['status']=='READY' + assert {args[0] for sql,args in db.calls if 'information_schema.COLUMNS' in sql}==set(service.database.table_allowlists) + db.names['cata_locale']=[] + result=deployment.check('worker','unused',loader=loader) + assert result['status']=='NOT_READY' and result['code']=='PROJECT_DATABASE_TABLE_UNKNOWN' + + +def test_full_vcore_multidatabase_template_loads_without_network(): + from pathlib import Path + service=ProjectResearch.load(Path(__file__).resolve().parents[1]/'config/project.vcore-multidb.example.toml') + assert set(service.database.table_allowlists)=={'cata_world','cata_hotfixes','cata_locale'} + assert service.database.user=='buganalysis' + assert service.relations and service.enrichment and service.entity_resolution + with pytest.raises(ToolError):service.database.resolve_table('cata_auth.accounts')