Support multiple allowlisted databases in project research tools
This commit is contained in:
1 parent
9408af5356
commit
9c9d9d70d6
20 files changed
+740
-53
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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"])):
|
||||
|
||||
@@ -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}. '
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]},
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
@@ -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 nLine truncated
|
||||
"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` nichLine truncated
|
||||
},
|
||||
{
|
||||
"id": "intake-api",
|
||||
|
||||
@@ -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')
|
||||
Reference in new issue
Block a user