feat(resource): add database resources #674
@@ -0,0 +1,180 @@
|
||||
"""ASV benchmarks for database resource types and transaction sandbox.
|
||||
|
||||
Measures the performance of:
|
||||
- Database type definition lookup
|
||||
- Connection argument resolution
|
||||
- Connection validation (SQLite)
|
||||
- TransactionSandbox create/commit/rollback lifecycle
|
||||
- SandboxFactory routing for transaction_rollback
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the local *source* tree is importable even when ASV has an
|
||||
# older build of the package installed.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
# Force-reload so ASV picks up the source tree version.
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.infrastructure.sandbox.factory import ( # noqa: E402
|
||||
SandboxFactory,
|
||||
)
|
||||
from cleveragents.infrastructure.sandbox.transaction_sandbox import ( # noqa: E402
|
||||
TransactionSandbox,
|
||||
)
|
||||
from cleveragents.resource.handlers.database import ( # noqa: E402
|
||||
DATABASE_TYPE_DEFS,
|
||||
DatabaseResourceHandler,
|
||||
resolve_connection_args,
|
||||
validate_connection,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type definition benchmarks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TypeDefinitionSuite:
|
||||
"""Benchmark database type definition operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_lookup_all_type_defs(self) -> None:
|
||||
"""Time iterating all database type definitions."""
|
||||
for td in DATABASE_TYPE_DEFS:
|
||||
_ = td["name"]
|
||||
|
||||
def time_resolve_postgres_args(self) -> None:
|
||||
"""Time resolving postgres connection args."""
|
||||
resolve_connection_args(
|
||||
{"host": "db.example.com", "port": 5432, "dbname": "mydb"},
|
||||
"postgres",
|
||||
)
|
||||
|
||||
def time_resolve_sqlite_args(self) -> None:
|
||||
"""Time resolving sqlite connection args."""
|
||||
resolve_connection_args({"path": "/tmp/test.db"}, "sqlite")
|
||||
|
||||
def time_handler_instantiation(self) -> None:
|
||||
"""Time creating a DatabaseResourceHandler."""
|
||||
DatabaseResourceHandler()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection validation benchmarks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ConnectionValidationSuite:
|
||||
"""Benchmark connection validation operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Create a temp SQLite database for benchmarking."""
|
||||
fd, self.db_path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute("CREATE TABLE bench (id INTEGER PRIMARY KEY)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def teardown(self) -> None:
|
||||
"""Remove the temp database."""
|
||||
if hasattr(self, "db_path") and os.path.exists(self.db_path):
|
||||
os.unlink(self.db_path)
|
||||
|
||||
def time_validate_sqlite_file(self) -> None:
|
||||
"""Time validating a file-based SQLite connection."""
|
||||
validate_connection("sqlite", {"path": self.db_path})
|
||||
|
||||
def time_validate_sqlite_memory(self) -> None:
|
||||
"""Time validating an in-memory SQLite connection."""
|
||||
validate_connection("sqlite", {"path": ":memory:"})
|
||||
|
||||
def time_validate_postgres_stub(self) -> None:
|
||||
"""Time validating a postgres stub (no driver)."""
|
||||
validate_connection(
|
||||
"postgres",
|
||||
{"host": "localhost", "port": 5432, "dbname": "bench"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transaction sandbox benchmarks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TransactionSandboxSuite:
|
||||
"""Benchmark TransactionSandbox lifecycle operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Create a temp SQLite database for benchmarking."""
|
||||
fd, self.db_path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute("CREATE TABLE bench (id INTEGER PRIMARY KEY, val TEXT)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def teardown(self) -> None:
|
||||
"""Remove the temp database."""
|
||||
if hasattr(self, "db_path") and os.path.exists(self.db_path):
|
||||
os.unlink(self.db_path)
|
||||
|
||||
def time_create_and_cleanup(self) -> None:
|
||||
"""Time sandbox create + cleanup cycle."""
|
||||
sandbox = TransactionSandbox(resource_id="bench-1", original_path=self.db_path)
|
||||
sandbox.create(plan_id="BENCH-PLAN")
|
||||
sandbox.cleanup()
|
||||
|
||||
def time_create_insert_commit(self) -> None:
|
||||
"""Time full sandbox lifecycle: create, insert, commit, cleanup."""
|
||||
sandbox = TransactionSandbox(resource_id="bench-2", original_path=self.db_path)
|
||||
sandbox.create(plan_id="BENCH-PLAN")
|
||||
sandbox.execute("INSERT INTO bench (id, val) VALUES (1, 'bench')")
|
||||
sandbox.commit("bench commit")
|
||||
sandbox.cleanup()
|
||||
|
||||
def time_create_insert_rollback(self) -> None:
|
||||
"""Time sandbox lifecycle with rollback."""
|
||||
sandbox = TransactionSandbox(resource_id="bench-3", original_path=self.db_path)
|
||||
sandbox.create(plan_id="BENCH-PLAN")
|
||||
sandbox.execute("INSERT INTO bench (id, val) VALUES (2, 'rollback')")
|
||||
sandbox.rollback()
|
||||
sandbox.cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory routing benchmarks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FactoryRoutingSuite:
|
||||
"""Benchmark SandboxFactory routing for database strategies."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_factory_transaction_rollback(self) -> None:
|
||||
"""Time factory creating a TransactionSandbox."""
|
||||
factory = SandboxFactory()
|
||||
factory.create_sandbox(
|
||||
resource_id="bench-factory",
|
||||
original_path=":memory:",
|
||||
sandbox_strategy="transaction_rollback",
|
||||
)
|
||||
@@ -738,10 +738,10 @@ Feature: Consolidated Sandbox
|
||||
# --- Unimplemented strategies ---
|
||||
|
||||
|
||||
Scenario: The transaction rollback strategy is not yet available
|
||||
Scenario: The transaction rollback strategy creates a TransactionSandbox
|
||||
Given the sandbox factory is available
|
||||
When a sandbox is requested for resource "db-main" at "postgres://db/main" using the transaction rollback strategy
|
||||
Then the factory should indicate the strategy is not yet implemented
|
||||
Then the factory should produce a transaction sandbox in the pending state
|
||||
|
||||
|
||||
Scenario: The snapshot strategy is not yet available
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
Feature: Database resource types and transaction sandbox
|
||||
As a CleverAgents developer
|
||||
I want database resource types with transaction-based sandboxing
|
||||
So that I can manage database resources with isolation and rollback
|
||||
|
||||
# ---- Resource Type Definitions ----
|
||||
|
||||
Scenario: Postgres resource type is defined
|
||||
Given the database type definitions are loaded
|
||||
Then the "postgres" type definition should exist
|
||||
And the "postgres" type should have sandbox strategy "transaction_rollback"
|
||||
And the "postgres" type should have a "host" cli argument
|
||||
And the "postgres" type should have a "port" cli argument
|
||||
And the "postgres" type should have a "dbname" cli argument
|
||||
And the "postgres" type should have a "user" cli argument
|
||||
And the "postgres" type should have a "password" cli argument
|
||||
And the "postgres" type should have a "connection-string" cli argument
|
||||
|
||||
Scenario: MySQL resource type is defined
|
||||
Given the database type definitions are loaded
|
||||
Then the "mysql" type definition should exist
|
||||
And the "mysql" type should have sandbox strategy "transaction_rollback"
|
||||
And the "mysql" type should have a "host" cli argument
|
||||
And the "mysql" type should have a "port" cli argument
|
||||
|
||||
Scenario: SQLite resource type is defined
|
||||
Given the database type definitions are loaded
|
||||
Then the "sqlite" type definition should exist
|
||||
And the "sqlite" type should have sandbox strategy "transaction_rollback"
|
||||
And the "sqlite" type should have a "path" cli argument
|
||||
|
||||
Scenario: DuckDB resource type is defined
|
||||
Given the database type definitions are loaded
|
||||
Then the "duckdb" type definition should exist
|
||||
And the "duckdb" type should have sandbox strategy "transaction_rollback"
|
||||
And the "duckdb" type should have a "path" cli argument
|
||||
|
||||
# ---- Connection Argument Validation ----
|
||||
|
||||
Scenario: Resolve postgres connection args from properties
|
||||
Given postgres connection properties with host "db.example.com" port 5432 dbname "mydb"
|
||||
When I resolve connection args for "postgres"
|
||||
Then the resolved args should have host "db.example.com"
|
||||
And the resolved args should have port 5432
|
||||
And the resolved args should have dbname "mydb"
|
||||
|
||||
Scenario: Resolve postgres connection args from connection string
|
||||
Given postgres properties with connection string "postgresql://admin:secret@db:5432/app"
|
||||
When I resolve connection args for "postgres"
|
||||
Then the resolved args should have connection string "postgresql://admin:secret@db:5432/app"
|
||||
|
||||
Scenario: Resolve postgres credentials from env vars
|
||||
Given postgres properties without explicit credentials
|
||||
And the env var "CLEVERAGENTS_DB_USER" is set to "envuser"
|
||||
And the env var "CLEVERAGENTS_DB_PASSWORD" is set to "envpass"
|
||||
When I resolve connection args for "postgres"
|
||||
Then the resolved args should have user "envuser"
|
||||
And the resolved args should have password "envpass"
|
||||
|
||||
Scenario: Resolve sqlite connection args
|
||||
Given sqlite properties with path "/tmp/test.db"
|
||||
When I resolve connection args for "sqlite"
|
||||
Then the resolved args should have path "/tmp/test.db"
|
||||
|
||||
Scenario: Resolve duckdb connection args with default memory
|
||||
Given duckdb properties with no path
|
||||
When I resolve connection args for "duckdb"
|
||||
Then the resolved args should have path ":memory:"
|
||||
|
||||
# ---- Credential Masking in Error Messages ----
|
||||
|
||||
Scenario: Credential masking in postgres validation message
|
||||
Given postgres connection properties with user "admin" and password "s3cret"
|
||||
When I validate the "postgres" connection
|
||||
Then the validation message should not contain "s3cret"
|
||||
And the validation result should be successful
|
||||
|
||||
Scenario: Connection string credentials are masked
|
||||
Given postgres properties with connection string "postgresql://admin:s3cret@db:5432/app"
|
||||
When I validate the "postgres" connection
|
||||
Then the validation message should not contain "s3cret"
|
||||
|
||||
# ---- Read-Only Enforcement ----
|
||||
|
||||
Scenario: Read-only transaction sandbox rejects writes
|
||||
Given a temporary SQLite database with a test table
|
||||
And a transaction sandbox in read-only mode
|
||||
When I attempt to insert data via the sandbox
|
||||
Then the sandbox should reject the write operation
|
||||
|
||||
# ---- Transaction Sandbox Create / Commit / Rollback ----
|
||||
|
||||
Scenario: Transaction sandbox lifecycle create and commit
|
||||
Given a temporary SQLite database with a test table
|
||||
And a transaction sandbox for the database
|
||||
When I create the sandbox with plan "PLAN-TX-001"
|
||||
And I insert a row via the sandbox
|
||||
And I commit the sandbox
|
||||
Then the row should be persisted in the database
|
||||
|
||||
Scenario: Transaction sandbox lifecycle create and rollback
|
||||
Given a temporary SQLite database with a test table
|
||||
And a transaction sandbox for the database
|
||||
When I create the sandbox with plan "PLAN-TX-002"
|
||||
And I insert a row via the sandbox
|
||||
And I rollback the sandbox
|
||||
Then the row should not be persisted in the database
|
||||
|
||||
Scenario: Transaction sandbox cleanup closes connection
|
||||
Given a temporary SQLite database with a test table
|
||||
And a transaction sandbox for the database
|
||||
When I create the sandbox with plan "PLAN-TX-003"
|
||||
And I cleanup the sandbox
|
||||
Then the sandbox status should be "cleaned_up"
|
||||
|
||||
# ---- SQLite Resource Handler Roundtrip ----
|
||||
|
||||
Scenario: SQLite validate_connection success
|
||||
Given a temporary SQLite database
|
||||
When I validate the sqlite connection
|
||||
Then the sqlite validation should succeed
|
||||
|
||||
Scenario: SQLite validate_connection with memory database
|
||||
When I validate an in-memory sqlite connection
|
||||
Then the sqlite validation should succeed
|
||||
|
||||
# ---- DatabaseResourceHandler Protocol ----
|
||||
|
||||
Scenario: DatabaseResourceHandler satisfies ResourceHandler protocol
|
||||
Given a DatabaseResourceHandler instance
|
||||
Then the handler should satisfy the ResourceHandler protocol
|
||||
|
||||
Scenario: DatabaseResourceHandler uses transaction_rollback strategy
|
||||
Given a DatabaseResourceHandler instance
|
||||
Then the handler default strategy should be "transaction_rollback"
|
||||
|
||||
# ---- SandboxFactory transaction_rollback routing ----
|
||||
|
||||
Scenario: SandboxFactory creates TransactionSandbox for transaction_rollback
|
||||
Given the sandbox factory is available
|
||||
When a sandbox is requested for resource "db-1" at ":memory:" using transaction_rollback
|
||||
Then the created sandbox should be a TransactionSandbox
|
||||
|
||||
Scenario: SandboxFactory reports transaction_rollback as supported
|
||||
Given the sandbox factory is available
|
||||
Then the factory should report "transaction_rollback" as supported
|
||||
|
||||
# ---- TransactionSandbox edge cases ----
|
||||
|
||||
Scenario: TransactionSandbox rejects empty resource_id
|
||||
When I create a TransactionSandbox with empty resource_id
|
||||
Then the edge error should be a ValueError mentioning "resource_id"
|
||||
|
||||
Scenario: TransactionSandbox rejects empty original_path
|
||||
When I create a TransactionSandbox with empty original_path
|
||||
Then the edge error should be a ValueError mentioning "original_path"
|
||||
|
||||
Scenario: TransactionSandbox rejects empty plan_id
|
||||
Given a temporary SQLite database with a test table
|
||||
And a transaction sandbox for the database
|
||||
When I try to create the sandbox with an empty plan_id
|
||||
Then the edge error should be a ValueError mentioning "plan_id"
|
||||
|
||||
Scenario: TransactionSandbox get_path returns db path
|
||||
Given a temporary SQLite database with a test table
|
||||
And a transaction sandbox for the database
|
||||
When I create the sandbox with plan "PLAN-PATH"
|
||||
Then get_path should return the database path
|
||||
|
||||
Scenario: TransactionSandbox get_path rejects traversal
|
||||
Given a temporary SQLite database with a test table
|
||||
And a transaction sandbox for the database
|
||||
When I create the sandbox with plan "PLAN-TRAV"
|
||||
Then get_path with traversal should raise ValueError
|
||||
|
||||
Scenario: TransactionSandbox get_path in wrong state raises
|
||||
Given a temporary SQLite database with a test table
|
||||
And a transaction sandbox for the database
|
||||
Then get_path before create should raise SandboxStateError
|
||||
|
||||
Scenario: TransactionSandbox commit in wrong state raises
|
||||
Given a temporary SQLite database with a test table
|
||||
And a transaction sandbox for the database
|
||||
Then commit before create should raise SandboxStateError
|
||||
|
||||
Scenario: TransactionSandbox rollback in wrong state raises
|
||||
Given a temporary SQLite database with a test table
|
||||
And a transaction sandbox for the database
|
||||
Then rollback before create should raise SandboxStateError
|
||||
|
||||
Scenario: TransactionSandbox cleanup is idempotent
|
||||
Given a temporary SQLite database with a test table
|
||||
And a transaction sandbox for the database
|
||||
When I create the sandbox with plan "PLAN-IDEM"
|
||||
And I cleanup the sandbox
|
||||
And I cleanup the sandbox again
|
||||
Then the sandbox status should be "cleaned_up"
|
||||
|
||||
Scenario: TransactionSandbox execute in wrong state raises
|
||||
Given a temporary SQLite database with a test table
|
||||
And a transaction sandbox for the database
|
||||
Then execute before create should raise SandboxStateError
|
||||
|
||||
Scenario: TransactionSandbox cleanup with active transaction
|
||||
Given a temporary SQLite database with a test table
|
||||
And a transaction sandbox for the database
|
||||
When I create the sandbox with plan "PLAN-ACT"
|
||||
And I insert a row via the sandbox
|
||||
And I cleanup the sandbox
|
||||
Then the sandbox status should be "cleaned_up"
|
||||
|
||||
Scenario: DuckDB validate_connection stub
|
||||
When I validate a duckdb connection with path ":memory:"
|
||||
Then the duckdb validation should succeed with a stub message
|
||||
|
||||
Scenario: MySQL validate_connection stub
|
||||
Given mysql connection properties with host "db.example.com" and port 3306
|
||||
When I validate the "mysql" connection
|
||||
Then the validation result should be successful
|
||||
|
||||
Scenario: Unknown database type validation
|
||||
When I validate an unknown database type
|
||||
Then the unknown validation should fail
|
||||
|
||||
Scenario: Resolve mysql connection args from connection string
|
||||
Given mysql properties with connection string "mysql://user:pass@host:3306/db"
|
||||
When I resolve connection args for "mysql"
|
||||
Then the resolved args should have connection string "mysql://user:pass@host:3306/db"
|
||||
@@ -0,0 +1,534 @@
|
||||
"""Step definitions for database resource types and transaction sandbox.
|
||||
|
||||
Covers:
|
||||
- Database type definitions (postgres, mysql, sqlite, duckdb)
|
||||
- Connection argument resolution and env var fallback
|
||||
- Credential masking in error/validation messages
|
||||
- Read-only enforcement
|
||||
- Transaction sandbox create/commit/rollback lifecycle
|
||||
- SQLite resource handler roundtrip
|
||||
- DatabaseResourceHandler protocol conformance
|
||||
- SandboxFactory transaction_rollback routing
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
|
||||
from cleveragents.infrastructure.sandbox.protocol import SandboxStatus
|
||||
from cleveragents.infrastructure.sandbox.transaction_sandbox import (
|
||||
TransactionSandbox,
|
||||
)
|
||||
from cleveragents.resource.handlers.database import (
|
||||
DATABASE_TYPE_DEFS,
|
||||
DatabaseResourceHandler,
|
||||
resolve_connection_args,
|
||||
validate_connection,
|
||||
)
|
||||
from cleveragents.resource.handlers.protocol import ResourceHandler
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_type_def(name):
|
||||
"""Find a database type definition by name."""
|
||||
for td in DATABASE_TYPE_DEFS:
|
||||
if td["name"] == name:
|
||||
return td
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resource Type Definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("the database type definitions are loaded")
|
||||
def given_db_type_defs_loaded(context):
|
||||
"""Load database type definitions into context."""
|
||||
context.db_type_defs = {td["name"]: td for td in DATABASE_TYPE_DEFS}
|
||||
|
||||
|
||||
@then('the "{name}" type definition should exist')
|
||||
def then_type_def_exists(context, name):
|
||||
assert name in context.db_type_defs, f"Type '{name}' not found in definitions"
|
||||
|
||||
|
||||
@then('the "{name}" type should have sandbox strategy "{strategy}"')
|
||||
def then_type_strategy(context, name, strategy):
|
||||
assert context.db_type_defs[name]["sandbox_strategy"] == strategy
|
||||
|
||||
|
||||
@then('the "{name}" type should have a "{arg_name}" cli argument')
|
||||
def then_type_has_arg(context, name, arg_name):
|
||||
cli_args = context.db_type_defs[name]["cli_args"]
|
||||
arg_names = [a["name"] for a in cli_args]
|
||||
assert arg_name in arg_names, f"Argument '{arg_name}' not found in {arg_names}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection Argument Resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given(
|
||||
'postgres connection properties with host "{host}" port {port:d} dbname "{dbname}"'
|
||||
)
|
||||
def given_pg_props(context, host, port, dbname):
|
||||
context.conn_props = {"host": host, "port": port, "dbname": dbname}
|
||||
|
||||
|
||||
@given('postgres properties with connection string "{conn_str}"')
|
||||
def given_pg_conn_str(context, conn_str):
|
||||
context.conn_props = {"connection-string": conn_str}
|
||||
|
||||
|
||||
@given("postgres properties without explicit credentials")
|
||||
def given_pg_no_creds(context):
|
||||
context.conn_props = {"host": "localhost", "port": 5432}
|
||||
|
||||
|
||||
@given('sqlite properties with path "{path}"')
|
||||
def given_sqlite_props(context, path):
|
||||
context.conn_props = {"path": path}
|
||||
|
||||
|
||||
@given("duckdb properties with no path")
|
||||
def given_duckdb_no_path(context):
|
||||
context.conn_props = {}
|
||||
|
||||
|
||||
@when('I resolve connection args for "{resource_type}"')
|
||||
def when_resolve_args(context, resource_type):
|
||||
context.resolved_args = resolve_connection_args(context.conn_props, resource_type)
|
||||
|
||||
|
||||
@then('the resolved args should have host "{host}"')
|
||||
def then_resolved_host(context, host):
|
||||
assert context.resolved_args["host"] == host
|
||||
|
||||
|
||||
@then("the resolved args should have port {port:d}")
|
||||
def then_resolved_port(context, port):
|
||||
assert context.resolved_args["port"] == port
|
||||
|
||||
|
||||
@then('the resolved args should have dbname "{dbname}"')
|
||||
def then_resolved_dbname(context, dbname):
|
||||
assert context.resolved_args["dbname"] == dbname
|
||||
|
||||
|
||||
@then('the resolved args should have connection string "{conn_str}"')
|
||||
def then_resolved_conn_str(context, conn_str):
|
||||
assert context.resolved_args["connection-string"] == conn_str
|
||||
|
||||
|
||||
@then('the resolved args should have user "{user}"')
|
||||
def then_resolved_user(context, user):
|
||||
args = context.resolved_args
|
||||
assert args["user"] == user, f"Expected user '{user}', got '{args['user']}'"
|
||||
_cleanup_env(context)
|
||||
|
||||
|
||||
@then('the resolved args should have password "{password}"')
|
||||
def then_resolved_password(context, password):
|
||||
args = context.resolved_args
|
||||
assert args["password"] == password
|
||||
_cleanup_env(context)
|
||||
|
||||
|
||||
@then('the resolved args should have path "{path}"')
|
||||
def then_resolved_path(context, path):
|
||||
assert context.resolved_args["path"] == path
|
||||
|
||||
|
||||
def _cleanup_env(context):
|
||||
"""Restore saved environment variables."""
|
||||
saved = getattr(context, "_saved_env", {})
|
||||
for var, old_value in saved.items():
|
||||
if old_value is None:
|
||||
os.environ.pop(var, None)
|
||||
else:
|
||||
os.environ[var] = old_value
|
||||
context._saved_env = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential Masking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('postgres connection properties with user "{user}" and password "{password}"')
|
||||
def given_pg_with_creds(context, user, password):
|
||||
context.conn_props = {
|
||||
"host": "localhost",
|
||||
"user": user,
|
||||
"password": password,
|
||||
}
|
||||
|
||||
|
||||
@when('I validate the "{resource_type}" connection')
|
||||
def when_validate_connection(context, resource_type):
|
||||
resolved = resolve_connection_args(context.conn_props, resource_type)
|
||||
success, message = validate_connection(resource_type, resolved)
|
||||
context.validation_success = success
|
||||
context.validation_message = message
|
||||
|
||||
|
||||
@then('the validation message should not contain "{secret}"')
|
||||
def then_message_no_secret(context, secret):
|
||||
msg = context.validation_message
|
||||
assert secret not in msg, f"Secret '{secret}' found in validation message: {msg}"
|
||||
|
||||
|
||||
@then("the validation result should be successful")
|
||||
def then_validation_success(context):
|
||||
assert context.validation_success is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read-Only Enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a temporary SQLite database with a test table")
|
||||
def given_temp_sqlite_db(context):
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
context.db_path = path
|
||||
|
||||
|
||||
@given("a transaction sandbox in read-only mode")
|
||||
def given_ro_sandbox(context):
|
||||
sandbox = TransactionSandbox(
|
||||
resource_id="res-ro-1",
|
||||
original_path=context.db_path,
|
||||
read_only=True,
|
||||
)
|
||||
sandbox.create(plan_id="PLAN-RO")
|
||||
context.tx_sandbox = sandbox
|
||||
|
||||
|
||||
@when("I attempt to insert data via the sandbox")
|
||||
def when_insert_readonly(context):
|
||||
sandbox = context.tx_sandbox
|
||||
try:
|
||||
sandbox.execute("INSERT INTO test_data (id, value) VALUES (1, 'forbidden')")
|
||||
context.write_rejected = False
|
||||
except Exception:
|
||||
context.write_rejected = True
|
||||
finally:
|
||||
sandbox.cleanup()
|
||||
|
||||
|
||||
@then("the sandbox should reject the write operation")
|
||||
def then_write_rejected(context):
|
||||
assert context.write_rejected is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transaction Sandbox Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a transaction sandbox for the database")
|
||||
def given_tx_sandbox(context):
|
||||
sandbox = TransactionSandbox(
|
||||
resource_id="res-tx-1",
|
||||
original_path=context.db_path,
|
||||
)
|
||||
context.tx_sandbox = sandbox
|
||||
|
||||
|
||||
@when('I create the sandbox with plan "{plan_id}"')
|
||||
def when_create_sandbox(context, plan_id):
|
||||
context.tx_sandbox.create(plan_id=plan_id)
|
||||
|
||||
|
||||
@when("I insert a row via the sandbox")
|
||||
def when_insert_row(context):
|
||||
context.tx_sandbox.execute(
|
||||
"INSERT INTO test_data (id, value) VALUES (99, 'test-value')"
|
||||
)
|
||||
|
||||
|
||||
@when("I commit the sandbox")
|
||||
def when_commit_sandbox(context):
|
||||
context.commit_result = context.tx_sandbox.commit("test commit")
|
||||
context.tx_sandbox.cleanup()
|
||||
|
||||
|
||||
@when("I rollback the sandbox")
|
||||
def when_rollback_sandbox(context):
|
||||
context.tx_sandbox.rollback()
|
||||
context.tx_sandbox.cleanup()
|
||||
|
||||
|
||||
@when("I cleanup the sandbox")
|
||||
def when_cleanup_sandbox(context):
|
||||
context.tx_sandbox.cleanup()
|
||||
|
||||
|
||||
@then("the row should be persisted in the database")
|
||||
def then_row_persisted(context):
|
||||
conn = sqlite3.connect(context.db_path)
|
||||
rows = conn.execute("SELECT value FROM test_data WHERE id = 99").fetchall()
|
||||
conn.close()
|
||||
assert len(rows) == 1, f"Expected 1 row, got {len(rows)}"
|
||||
assert rows[0][0] == "test-value"
|
||||
|
||||
|
||||
@then("the row should not be persisted in the database")
|
||||
def then_row_not_persisted(context):
|
||||
conn = sqlite3.connect(context.db_path)
|
||||
rows = conn.execute("SELECT value FROM test_data WHERE id = 99").fetchall()
|
||||
conn.close()
|
||||
assert len(rows) == 0, f"Expected 0 rows, got {len(rows)}"
|
||||
|
||||
|
||||
@then('the sandbox status should be "{status}"')
|
||||
def then_sandbox_status(context, status):
|
||||
assert context.tx_sandbox.status == SandboxStatus(status)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQLite Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a temporary SQLite database")
|
||||
def given_temp_sqlite(context):
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
context.sqlite_path = path
|
||||
|
||||
|
||||
@when("I validate the sqlite connection")
|
||||
def when_validate_sqlite(context):
|
||||
success, msg = validate_connection("sqlite", {"path": context.sqlite_path})
|
||||
context.sqlite_valid_ok = success
|
||||
context.sqlite_valid_msg = msg
|
||||
|
||||
|
||||
@when("I validate an in-memory sqlite connection")
|
||||
def when_validate_sqlite_memory(context):
|
||||
success, msg = validate_connection("sqlite", {"path": ":memory:"})
|
||||
context.sqlite_valid_ok = success
|
||||
context.sqlite_valid_msg = msg
|
||||
|
||||
|
||||
@then("the sqlite validation should succeed")
|
||||
def then_sqlite_valid(context):
|
||||
assert context.sqlite_valid_ok is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DatabaseResourceHandler Protocol
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a DatabaseResourceHandler instance")
|
||||
def given_db_handler(context):
|
||||
context.db_handler = DatabaseResourceHandler()
|
||||
|
||||
|
||||
@then("the handler should satisfy the ResourceHandler protocol")
|
||||
def then_handler_protocol(context):
|
||||
assert isinstance(context.db_handler, ResourceHandler)
|
||||
|
||||
|
||||
@then('the handler default strategy should be "{strategy}"')
|
||||
def then_handler_strategy(context, strategy):
|
||||
assert context.db_handler._default_strategy.value == strategy
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SandboxFactory transaction_rollback routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when(
|
||||
'a sandbox is requested for resource "{res_id}" '
|
||||
'at "{path}" using transaction_rollback'
|
||||
)
|
||||
def when_factory_tx(context, res_id, path):
|
||||
context.factory_sandbox = context.sandbox_factory.create_sandbox(
|
||||
resource_id=res_id,
|
||||
original_path=path,
|
||||
sandbox_strategy="transaction_rollback",
|
||||
)
|
||||
|
||||
|
||||
@then("the created sandbox should be a TransactionSandbox")
|
||||
def then_sandbox_is_tx(context):
|
||||
assert isinstance(context.factory_sandbox, TransactionSandbox)
|
||||
|
||||
|
||||
@then('the factory should report "{strategy}" as supported')
|
||||
def then_factory_supported(context, strategy):
|
||||
assert SandboxFactory.is_supported(strategy) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TransactionSandbox edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I create a TransactionSandbox with empty resource_id")
|
||||
def when_tx_empty_res_id(context):
|
||||
try:
|
||||
TransactionSandbox(resource_id="", original_path="/tmp/x")
|
||||
context.edge_error = None
|
||||
except ValueError as exc:
|
||||
context.edge_error = exc
|
||||
|
||||
|
||||
@when("I create a TransactionSandbox with empty original_path")
|
||||
def when_tx_empty_path(context):
|
||||
try:
|
||||
TransactionSandbox(resource_id="res-1", original_path="")
|
||||
context.edge_error = None
|
||||
except ValueError as exc:
|
||||
context.edge_error = exc
|
||||
|
||||
|
||||
@then('the edge error should be a ValueError mentioning "{fragment}"')
|
||||
def then_value_error_with_msg(context, fragment):
|
||||
assert context.edge_error is not None, "Expected a ValueError"
|
||||
assert isinstance(context.edge_error, ValueError)
|
||||
assert fragment in str(context.edge_error)
|
||||
|
||||
|
||||
@when("I try to create the sandbox with an empty plan_id")
|
||||
def when_empty_plan_id(context):
|
||||
try:
|
||||
context.tx_sandbox.create(plan_id="")
|
||||
context.edge_error = None
|
||||
except ValueError as exc:
|
||||
context.edge_error = exc
|
||||
|
||||
|
||||
@then("get_path should return the database path")
|
||||
def then_get_path_returns_db(context):
|
||||
sandbox = context.tx_sandbox
|
||||
result = sandbox.get_path("table")
|
||||
assert result == context.db_path
|
||||
|
||||
|
||||
@then("get_path with traversal should raise ValueError")
|
||||
def then_get_path_traversal(context):
|
||||
sandbox = context.tx_sandbox
|
||||
raised = False
|
||||
try:
|
||||
sandbox.get_path("../../etc/passwd")
|
||||
except ValueError:
|
||||
raised = True
|
||||
assert raised, "Expected ValueError for path traversal"
|
||||
|
||||
|
||||
@then("get_path before create should raise SandboxStateError")
|
||||
def then_get_path_wrong_state(context):
|
||||
from cleveragents.infrastructure.sandbox.protocol import SandboxStateError
|
||||
|
||||
sandbox = context.tx_sandbox
|
||||
raised = False
|
||||
try:
|
||||
sandbox.get_path("test")
|
||||
except SandboxStateError:
|
||||
raised = True
|
||||
assert raised, "Expected SandboxStateError"
|
||||
|
||||
|
||||
@then("commit before create should raise SandboxStateError")
|
||||
def then_commit_wrong_state(context):
|
||||
from cleveragents.infrastructure.sandbox.protocol import SandboxStateError
|
||||
|
||||
sandbox = context.tx_sandbox
|
||||
raised = False
|
||||
try:
|
||||
sandbox.commit("test")
|
||||
except SandboxStateError:
|
||||
raised = True
|
||||
assert raised, "Expected SandboxStateError"
|
||||
|
||||
|
||||
@then("rollback before create should raise SandboxStateError")
|
||||
def then_rollback_wrong_state(context):
|
||||
from cleveragents.infrastructure.sandbox.protocol import SandboxStateError
|
||||
|
||||
sandbox = context.tx_sandbox
|
||||
raised = False
|
||||
try:
|
||||
sandbox.rollback()
|
||||
except SandboxStateError:
|
||||
raised = True
|
||||
assert raised, "Expected SandboxStateError"
|
||||
|
||||
|
||||
@when("I cleanup the sandbox again")
|
||||
def when_cleanup_again(context):
|
||||
context.tx_sandbox.cleanup()
|
||||
|
||||
|
||||
@then("execute before create should raise SandboxStateError")
|
||||
def then_execute_wrong_state(context):
|
||||
from cleveragents.infrastructure.sandbox.protocol import SandboxStateError
|
||||
|
||||
sandbox = context.tx_sandbox
|
||||
raised = False
|
||||
try:
|
||||
sandbox.execute("SELECT 1")
|
||||
except SandboxStateError:
|
||||
raised = True
|
||||
assert raised, "Expected SandboxStateError"
|
||||
|
||||
|
||||
@when('I validate a duckdb connection with path "{path}"')
|
||||
def when_validate_duckdb(context, path):
|
||||
success, msg = validate_connection("duckdb", {"path": path})
|
||||
context.duckdb_valid_ok = success
|
||||
context.duckdb_valid_msg = msg
|
||||
|
||||
|
||||
@then("the duckdb validation should succeed with a stub message")
|
||||
def then_duckdb_stub(context):
|
||||
assert context.duckdb_valid_ok is True
|
||||
# Either driver works or stub message about not installed
|
||||
assert (
|
||||
"DuckDB" in context.duckdb_valid_msg
|
||||
or "duckdb" in context.duckdb_valid_msg.lower()
|
||||
)
|
||||
|
||||
|
||||
@given('mysql connection properties with host "{host}" and port {port:d}')
|
||||
def given_mysql_props(context, host, port):
|
||||
context.conn_props = {"host": host, "port": port}
|
||||
|
||||
|
||||
@when("I validate an unknown database type")
|
||||
def when_validate_unknown_db(context):
|
||||
success, msg = validate_connection("unknown_db", {})
|
||||
context.unknown_valid_ok = success
|
||||
context.unknown_valid_msg = msg
|
||||
|
||||
|
||||
@then("the unknown validation should fail")
|
||||
def then_unknown_fail(context):
|
||||
assert context.unknown_valid_ok is False
|
||||
assert "Unknown" in context.unknown_valid_msg
|
||||
|
||||
|
||||
@given('mysql properties with connection string "{conn_str}"')
|
||||
def given_mysql_conn_str(context, conn_str):
|
||||
context.conn_props = {"connection-string": conn_str}
|
||||
@@ -5,7 +5,8 @@ Covers lines in ``factory.py``:
|
||||
- none strategy -> NoSandbox
|
||||
- git_worktree strategy -> GitWorktreeSandbox
|
||||
- copy_on_write strategy -> CopyOnWriteSandbox
|
||||
- transaction_rollback / snapshot -> NotImplementedError
|
||||
- transaction_rollback -> TransactionSandbox
|
||||
- snapshot -> NotImplementedError
|
||||
- overlay / versioning (removed) -> ValueError (unknown)
|
||||
- Unknown strategy -> ValueError
|
||||
- is_supported() for all strategies
|
||||
@@ -21,6 +22,7 @@ from cleveragents.infrastructure.sandbox.factory import SandboxFactory
|
||||
from cleveragents.infrastructure.sandbox.git_worktree import GitWorktreeSandbox
|
||||
from cleveragents.infrastructure.sandbox.no_sandbox import NoSandbox
|
||||
from cleveragents.infrastructure.sandbox.protocol import SandboxStatus
|
||||
from cleveragents.infrastructure.sandbox.transaction_sandbox import TransactionSandbox
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Givens
|
||||
@@ -131,14 +133,11 @@ def when_factory_overlay(context, res_id: str, path: str):
|
||||
def when_factory_transaction_rollback(context, res_id: str, path: str):
|
||||
"""Request a sandbox with the 'transaction_rollback' strategy."""
|
||||
context.factory_error = None
|
||||
try:
|
||||
context.sandbox_factory.create_sandbox(
|
||||
resource_id=res_id,
|
||||
original_path=path,
|
||||
sandbox_strategy="transaction_rollback",
|
||||
)
|
||||
except NotImplementedError as exc:
|
||||
context.factory_error = exc
|
||||
context.factory_sandbox = context.sandbox_factory.create_sandbox(
|
||||
resource_id=res_id,
|
||||
original_path=path,
|
||||
sandbox_strategy="transaction_rollback",
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
@@ -338,6 +337,13 @@ def then_factory_not_implemented(context):
|
||||
assert isinstance(context.factory_error, NotImplementedError)
|
||||
|
||||
|
||||
@then("the factory should produce a transaction sandbox in the pending state")
|
||||
def then_factory_produces_transaction_sandbox(context):
|
||||
"""Assert the returned sandbox is a TransactionSandbox in PENDING."""
|
||||
assert isinstance(context.factory_sandbox, TransactionSandbox)
|
||||
assert context.factory_sandbox.status == SandboxStatus.PENDING
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thens - is_supported
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
*** Settings ***
|
||||
Documentation Integration smoke tests for database resource types and transaction sandbox
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_database_resources.py
|
||||
|
||||
*** Test Cases ***
|
||||
Database Handler Protocol Conformance
|
||||
[Documentation] Verify DatabaseResourceHandler satisfies ResourceHandler
|
||||
${result}= Run Process ${PYTHON} ${HELPER} protocol-check cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} protocol-check-ok
|
||||
|
||||
Database Type Definitions Present
|
||||
[Documentation] Verify all 4 database type definitions are present
|
||||
${result}= Run Process ${PYTHON} ${HELPER} type-defs cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} type-defs-ok
|
||||
|
||||
SQLite Connection Validation
|
||||
[Documentation] Verify SQLite connection validation succeeds
|
||||
${result}= Run Process ${PYTHON} ${HELPER} sqlite-validate cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} sqlite-validate-ok
|
||||
|
||||
Transaction Sandbox Commit
|
||||
[Documentation] Verify TransactionSandbox commit persists data
|
||||
${result}= Run Process ${PYTHON} ${HELPER} tx-commit cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} tx-commit-ok
|
||||
|
||||
Transaction Sandbox Rollback
|
||||
[Documentation] Verify TransactionSandbox rollback discards data
|
||||
${result}= Run Process ${PYTHON} ${HELPER} tx-rollback cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} tx-rollback-ok
|
||||
|
||||
Credential Masking In Validation
|
||||
[Documentation] Verify credentials are masked in validation output
|
||||
${result}= Run Process ${PYTHON} ${HELPER} cred-mask cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} cred-mask-ok
|
||||
|
||||
SandboxFactory Transaction Rollback Routing
|
||||
[Documentation] Verify SandboxFactory creates TransactionSandbox for transaction_rollback
|
||||
${result}= Run Process ${PYTHON} ${HELPER} factory-routing cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} factory-routing-ok
|
||||
|
||||
Read Only Enforcement
|
||||
[Documentation] Verify read-only mode prevents writes
|
||||
${result}= Run Process ${PYTHON} ${HELPER} read-only cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} read-only-ok
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Helper utilities for database resource Robot smoke tests.
|
||||
|
||||
Each command prints a single ``<command>-ok`` token on success so the
|
||||
calling Robot test can assert on ``stdout``. Only uses sqlite3 (stdlib)
|
||||
for concrete database testing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
|
||||
from cleveragents.infrastructure.sandbox.transaction_sandbox import (
|
||||
TransactionSandbox,
|
||||
)
|
||||
from cleveragents.resource.handlers.database import (
|
||||
DATABASE_TYPE_DEFS,
|
||||
DATABASE_TYPE_NAMES,
|
||||
DatabaseResourceHandler,
|
||||
resolve_connection_args,
|
||||
validate_connection,
|
||||
)
|
||||
from cleveragents.resource.handlers.protocol import ResourceHandler
|
||||
from cleveragents.shared.redaction import mask_database_url
|
||||
|
||||
|
||||
def _protocol_check() -> None:
|
||||
"""Verify DatabaseResourceHandler satisfies ResourceHandler."""
|
||||
handler = DatabaseResourceHandler()
|
||||
assert isinstance(handler, ResourceHandler), (
|
||||
"DatabaseResourceHandler not a ResourceHandler"
|
||||
)
|
||||
print("protocol-check-ok")
|
||||
|
||||
|
||||
def _type_defs() -> None:
|
||||
"""Verify all 4 database type definitions are present."""
|
||||
names = {td["name"] for td in DATABASE_TYPE_DEFS}
|
||||
for expected in ("postgres", "mysql", "sqlite", "duckdb"):
|
||||
assert expected in names, f"Missing type def: {expected}"
|
||||
assert names == DATABASE_TYPE_NAMES
|
||||
print("type-defs-ok")
|
||||
|
||||
|
||||
def _sqlite_validate() -> None:
|
||||
"""Validate SQLite connection with a temp file."""
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
try:
|
||||
success, msg = validate_connection("sqlite", {"path": path})
|
||||
assert success, f"SQLite validation failed: {msg}"
|
||||
print("sqlite-validate-ok")
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def _tx_commit() -> None:
|
||||
"""Test transaction sandbox commit persists data."""
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
try:
|
||||
# Setup table
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Use sandbox
|
||||
sandbox = TransactionSandbox(resource_id="robot-tx-1", original_path=path)
|
||||
sandbox.create(plan_id="PLAN-ROBOT-COMMIT")
|
||||
sandbox.execute("INSERT INTO items (id, name) VALUES (1, 'committed')")
|
||||
result = sandbox.commit("robot commit")
|
||||
assert result.success
|
||||
sandbox.cleanup()
|
||||
|
||||
# Verify data persisted
|
||||
conn = sqlite3.connect(path)
|
||||
rows = conn.execute("SELECT name FROM items WHERE id = 1").fetchall()
|
||||
conn.close()
|
||||
assert len(rows) == 1
|
||||
assert rows[0][0] == "committed"
|
||||
print("tx-commit-ok")
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def _tx_rollback() -> None:
|
||||
"""Test transaction sandbox rollback discards data."""
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
try:
|
||||
# Setup table
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Use sandbox
|
||||
sandbox = TransactionSandbox(resource_id="robot-tx-2", original_path=path)
|
||||
sandbox.create(plan_id="PLAN-ROBOT-ROLLBACK")
|
||||
sandbox.execute("INSERT INTO items (id, name) VALUES (2, 'rolled-back')")
|
||||
# Transition to ACTIVE by executing, then rollback
|
||||
sandbox.rollback()
|
||||
sandbox.cleanup()
|
||||
|
||||
# Verify data NOT persisted
|
||||
conn = sqlite3.connect(path)
|
||||
rows = conn.execute("SELECT name FROM items WHERE id = 2").fetchall()
|
||||
conn.close()
|
||||
assert len(rows) == 0
|
||||
print("tx-rollback-ok")
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def _cred_mask() -> None:
|
||||
"""Verify credential masking in validation messages."""
|
||||
props = {"connection-string": "postgresql://admin:supersecret@db:5432/app"}
|
||||
resolved = resolve_connection_args(props, "postgres")
|
||||
_success, msg = validate_connection("postgres", resolved)
|
||||
assert "supersecret" not in msg, f"Secret leaked: {msg}"
|
||||
# Also test mask_database_url directly
|
||||
masked = mask_database_url("postgresql://admin:supersecret@db:5432/app")
|
||||
assert "supersecret" not in masked
|
||||
print("cred-mask-ok")
|
||||
|
||||
|
||||
def _factory_routing() -> None:
|
||||
"""Verify SandboxFactory routes transaction_rollback correctly."""
|
||||
factory = SandboxFactory()
|
||||
sandbox = factory.create_sandbox(
|
||||
resource_id="factory-test",
|
||||
original_path=":memory:",
|
||||
sandbox_strategy="transaction_rollback",
|
||||
)
|
||||
assert isinstance(sandbox, TransactionSandbox)
|
||||
assert SandboxFactory.is_supported("transaction_rollback")
|
||||
print("factory-routing-ok")
|
||||
|
||||
|
||||
def _read_only() -> None:
|
||||
"""Verify read-only sandbox prevents writes."""
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
try:
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
sandbox = TransactionSandbox(
|
||||
resource_id="ro-test", original_path=path, read_only=True
|
||||
)
|
||||
sandbox.create(plan_id="PLAN-RO-ROBOT")
|
||||
rejected = False
|
||||
try:
|
||||
sandbox.execute("INSERT INTO items (id, name) VALUES (1, 'forbidden')")
|
||||
except Exception:
|
||||
rejected = True
|
||||
finally:
|
||||
sandbox.cleanup()
|
||||
assert rejected, "Write was not rejected in read-only mode"
|
||||
print("read-only-ok")
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
_COMMANDS = {
|
||||
"protocol-check": _protocol_check,
|
||||
"type-defs": _type_defs,
|
||||
"sqlite-validate": _sqlite_validate,
|
||||
"tx-commit": _tx_commit,
|
||||
"tx-rollback": _tx_rollback,
|
||||
"cred-mask": _cred_mask,
|
||||
"factory-routing": _factory_routing,
|
||||
"read-only": _read_only,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
|
||||
print(
|
||||
f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
_COMMANDS[sys.argv[1]]()
|
||||
@@ -155,17 +155,17 @@ def _factory_create_none_strategy() -> None:
|
||||
)
|
||||
assert isinstance(cow_sandbox, CopyOnWriteSandbox)
|
||||
|
||||
# Verify unimplemented strategies still raise
|
||||
try:
|
||||
factory.create_sandbox(
|
||||
resource_id="res-006",
|
||||
original_path="/tmp/test",
|
||||
sandbox_strategy="transaction_rollback",
|
||||
)
|
||||
print("FAIL: Expected NotImplementedError for transaction_rollback")
|
||||
return
|
||||
except NotImplementedError:
|
||||
pass
|
||||
# Verify transaction_rollback now produces a TransactionSandbox
|
||||
from cleveragents.infrastructure.sandbox.transaction_sandbox import (
|
||||
TransactionSandbox,
|
||||
)
|
||||
|
||||
tx_sandbox = factory.create_sandbox(
|
||||
resource_id="res-006",
|
||||
original_path=":memory:",
|
||||
sandbox_strategy="transaction_rollback",
|
||||
)
|
||||
assert isinstance(tx_sandbox, TransactionSandbox)
|
||||
|
||||
print("factory-create-none-ok")
|
||||
|
||||
@@ -177,8 +177,10 @@ def _factory_supported_strategies() -> None:
|
||||
assert SandboxFactory.is_supported("git_worktree")
|
||||
assert SandboxFactory.is_supported("copy_on_write")
|
||||
|
||||
# transaction_rollback is now supported
|
||||
assert SandboxFactory.is_supported("transaction_rollback")
|
||||
|
||||
# Unimplemented strategies are not supported
|
||||
assert not SandboxFactory.is_supported("transaction_rollback")
|
||||
assert not SandboxFactory.is_supported("snapshot")
|
||||
|
||||
# Resource type strategy mapping (spec-aligned)
|
||||
|
||||
@@ -28,6 +28,7 @@ from cleveragents.infrastructure.database.models import (
|
||||
ResourceModel,
|
||||
ResourceTypeModel,
|
||||
)
|
||||
from cleveragents.resource.handlers.database import DATABASE_TYPE_DEFS
|
||||
|
||||
__all__ = [
|
||||
"BUILTIN_TYPES",
|
||||
@@ -194,6 +195,7 @@ BUILTIN_TYPES: list[dict[str, Any]] = [
|
||||
"checkpoint": False,
|
||||
},
|
||||
},
|
||||
*DATABASE_TYPE_DEFS,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -162,6 +162,10 @@ class ResourceTypeSpec(BaseModel):
|
||||
"container-instance",
|
||||
"devcontainer-instance",
|
||||
"devcontainer-file",
|
||||
"postgres",
|
||||
"mysql",
|
||||
"sqlite",
|
||||
"duckdb",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ from cleveragents.infrastructure.sandbox.protocol import (
|
||||
SandboxError,
|
||||
SandboxStatus,
|
||||
)
|
||||
from cleveragents.infrastructure.sandbox.transaction_sandbox import TransactionSandbox
|
||||
|
||||
__all__ = [
|
||||
"BoundaryCache",
|
||||
@@ -63,6 +64,7 @@ __all__ = [
|
||||
"SandboxManager",
|
||||
"SandboxStatus",
|
||||
"SequentialMergeStrategy",
|
||||
"TransactionSandbox",
|
||||
"compute_sandbox_domains",
|
||||
"is_sandbox_boundary",
|
||||
"sandbox_boundary",
|
||||
|
||||
@@ -19,6 +19,7 @@ from cleveragents.infrastructure.sandbox.no_sandbox import NoSandbox
|
||||
from cleveragents.infrastructure.sandbox.protocol import (
|
||||
Sandbox,
|
||||
)
|
||||
from cleveragents.infrastructure.sandbox.transaction_sandbox import TransactionSandbox
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,7 +40,7 @@ SandboxStrategyStr = Literal[
|
||||
|
||||
# Strategies that have concrete implementations
|
||||
_IMPLEMENTED_STRATEGIES: frozenset[str] = frozenset(
|
||||
{"none", "git_worktree", "copy_on_write"}
|
||||
{"none", "git_worktree", "copy_on_write", "transaction_rollback"}
|
||||
)
|
||||
|
||||
# Resource type to supported strategies mapping (spec-aligned)
|
||||
@@ -50,6 +51,10 @@ _SUPPORTED_STRATEGIES: dict[str, list[SandboxStrategyStr]] = {
|
||||
"fs-directory": ["copy_on_write", "none"],
|
||||
"fs-file": ["copy_on_write", "none"],
|
||||
"api_endpoint": ["none"],
|
||||
"postgres": ["transaction_rollback", "none"],
|
||||
"mysql": ["transaction_rollback", "none"],
|
||||
"sqlite": ["transaction_rollback", "none"],
|
||||
"duckdb": ["transaction_rollback", "none"],
|
||||
}
|
||||
|
||||
|
||||
@@ -62,8 +67,9 @@ class SandboxFactory:
|
||||
- ``"none"`` -> :class:`NoSandbox`
|
||||
- ``"git_worktree"`` -> :class:`GitWorktreeSandbox`
|
||||
- ``"copy_on_write"`` -> :class:`CopyOnWriteSandbox`
|
||||
- ``"transaction_rollback"`` -> :class:`TransactionSandbox`
|
||||
|
||||
``"transaction_rollback"`` and ``"snapshot"`` raise ``NotImplementedError``.
|
||||
``"snapshot"`` raises ``NotImplementedError``.
|
||||
"""
|
||||
|
||||
def create_sandbox(
|
||||
@@ -112,8 +118,9 @@ class SandboxFactory:
|
||||
)
|
||||
|
||||
if sandbox_strategy == STRATEGY_TRANSACTION_ROLLBACK:
|
||||
raise NotImplementedError(
|
||||
"Database transaction sandbox not yet implemented"
|
||||
return TransactionSandbox(
|
||||
resource_id=resource_id,
|
||||
original_path=original_path,
|
||||
)
|
||||
|
||||
if sandbox_strategy == STRATEGY_SNAPSHOT:
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
"""Transaction-rollback sandbox for database resources.
|
||||
|
||||
Provides isolation through database transaction semantics
|
||||
(``BEGIN`` / ``ROLLBACK`` / ``COMMIT``). The concrete backend
|
||||
uses Python's stdlib ``sqlite3`` module; PostgreSQL and MySQL
|
||||
backends are stubs that raise ``NotImplementedError``.
|
||||
|
||||
Read-only mode is supported via SQLite's ``PRAGMA query_only``.
|
||||
|
||||
Implements the
|
||||
:class:`~cleveragents.infrastructure.sandbox.protocol.Sandbox`
|
||||
protocol.
|
||||
|
||||
Lifecycle::
|
||||
|
||||
sandbox = TransactionSandbox(resource_id, original_path)
|
||||
ctx = sandbox.create(plan_id) # opens connection, BEGIN
|
||||
sandbox.execute("INSERT ...") # within transaction
|
||||
result = sandbox.commit("msg") # COMMIT
|
||||
sandbox.cleanup() # close connection
|
||||
|
||||
Based on:
|
||||
- implementation_plan.md group M7.post-resource-db
|
||||
- Sandbox protocol (B3.1)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
from ulid import ULID
|
||||
|
||||
from cleveragents.infrastructure.sandbox.protocol import (
|
||||
CommitResult,
|
||||
SandboxCommitError,
|
||||
SandboxContext,
|
||||
SandboxCreationError,
|
||||
SandboxRollbackError,
|
||||
SandboxStateError,
|
||||
SandboxStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TransactionSandbox:
|
||||
"""Sandbox that isolates changes using database transactions.
|
||||
|
||||
Opens a database connection and wraps all operations in a
|
||||
transaction. ``commit()`` issues ``COMMIT``; ``rollback()``
|
||||
issues ``ROLLBACK``.
|
||||
|
||||
Currently only SQLite is fully implemented via the stdlib
|
||||
``sqlite3`` module. The ``original_path`` parameter is
|
||||
interpreted as the SQLite database file path (or ``:memory:``).
|
||||
|
||||
Implements the
|
||||
:class:`~cleveragents.infrastructure.sandbox.protocol.Sandbox`
|
||||
protocol.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource_id: str,
|
||||
original_path: str,
|
||||
*,
|
||||
read_only: bool = False,
|
||||
) -> None:
|
||||
"""Initialise a transaction sandbox.
|
||||
|
||||
Args:
|
||||
resource_id: Identifier of the database resource.
|
||||
original_path: Database file path or connection string.
|
||||
read_only: When ``True``, enforce read-only mode on the
|
||||
connection (SQLite ``PRAGMA query_only = ON``).
|
||||
|
||||
Raises:
|
||||
ValueError: If *resource_id* is empty.
|
||||
ValueError: If *original_path* is empty.
|
||||
"""
|
||||
if not resource_id:
|
||||
raise ValueError("resource_id cannot be empty")
|
||||
if not original_path:
|
||||
raise ValueError("original_path cannot be empty")
|
||||
|
||||
self._sandbox_id: str = str(ULID())
|
||||
self._resource_id: str = resource_id
|
||||
self._original_path: str = original_path
|
||||
self._read_only: bool = read_only
|
||||
self._status: SandboxStatus = SandboxStatus.PENDING
|
||||
self._context: SandboxContext | None = None
|
||||
self._connection: sqlite3.Connection | None = None
|
||||
|
||||
# -- protocol properties -------------------------------------------------
|
||||
|
||||
@property
|
||||
def sandbox_id(self) -> str:
|
||||
"""Unique identifier for this sandbox instance."""
|
||||
return self._sandbox_id
|
||||
|
||||
@property
|
||||
def status(self) -> SandboxStatus:
|
||||
"""Current lifecycle status."""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def context(self) -> SandboxContext | None:
|
||||
"""Context after creation, ``None`` before ``create``."""
|
||||
return self._context
|
||||
|
||||
# -- protocol methods ----------------------------------------------------
|
||||
|
||||
def create(self, plan_id: str) -> SandboxContext:
|
||||
"""Open a database connection and begin a transaction.
|
||||
|
||||
Args:
|
||||
plan_id: The plan that owns this sandbox.
|
||||
|
||||
Returns:
|
||||
A :class:`SandboxContext` with metadata about the
|
||||
transaction sandbox.
|
||||
|
||||
Raises:
|
||||
ValueError: If *plan_id* is empty.
|
||||
SandboxStateError: If not in ``PENDING`` status.
|
||||
SandboxCreationError: If the database connection fails.
|
||||
"""
|
||||
if not plan_id:
|
||||
raise ValueError("plan_id cannot be empty")
|
||||
|
||||
SandboxStatus.assert_transition(self._status, SandboxStatus.CREATED)
|
||||
|
||||
try:
|
||||
self._connection = sqlite3.connect(
|
||||
self._original_path,
|
||||
isolation_level=None, # manual transaction control
|
||||
)
|
||||
# Begin explicit transaction
|
||||
self._connection.execute("BEGIN")
|
||||
|
||||
if self._read_only:
|
||||
self._connection.execute("PRAGMA query_only = ON")
|
||||
|
||||
except sqlite3.Error as exc:
|
||||
self._status = SandboxStatus.ERRORED
|
||||
raise SandboxCreationError(
|
||||
f"Failed to open database for resource {self._resource_id}: {exc}"
|
||||
) from exc
|
||||
|
||||
self._context = SandboxContext(
|
||||
sandbox_id=self._sandbox_id,
|
||||
sandbox_path=self._original_path,
|
||||
original_path=self._original_path,
|
||||
resource_id=self._resource_id,
|
||||
plan_id=plan_id,
|
||||
created_at=datetime.now(),
|
||||
metadata={
|
||||
"strategy": "transaction_rollback",
|
||||
"read_only": self._read_only,
|
||||
"db_path": self._original_path,
|
||||
},
|
||||
)
|
||||
self._status = SandboxStatus.CREATED
|
||||
|
||||
logger.info(
|
||||
"Created transaction sandbox: plan=%s resource=%s path=%s read_only=%s",
|
||||
plan_id,
|
||||
self._resource_id,
|
||||
self._original_path,
|
||||
self._read_only,
|
||||
)
|
||||
|
||||
return self._context
|
||||
|
||||
def get_path(self, resource_path: str) -> str:
|
||||
"""Return the database path (resource_path is ignored for DB).
|
||||
|
||||
For database sandboxes the ``resource_path`` argument is not
|
||||
meaningful in the filesystem sense. This method returns the
|
||||
database file path for compatibility with the protocol.
|
||||
|
||||
Args:
|
||||
resource_path: Ignored (kept for protocol compatibility).
|
||||
|
||||
Returns:
|
||||
The database file path.
|
||||
|
||||
Raises:
|
||||
SandboxStateError: If sandbox is not in a usable status.
|
||||
ValueError: If *resource_path* attempts directory traversal.
|
||||
"""
|
||||
if self._status not in (
|
||||
SandboxStatus.CREATED,
|
||||
SandboxStatus.ACTIVE,
|
||||
):
|
||||
raise SandboxStateError(
|
||||
f"Cannot resolve path in status {self._status.value}"
|
||||
)
|
||||
|
||||
if ".." in resource_path.split("/"):
|
||||
raise ValueError(f"Path traversal not allowed: {resource_path}")
|
||||
|
||||
if self._status == SandboxStatus.CREATED:
|
||||
self._status = SandboxStatus.ACTIVE
|
||||
|
||||
return self._original_path
|
||||
|
||||
def commit(self, message: str | None = None) -> CommitResult:
|
||||
"""Commit the database transaction.
|
||||
|
||||
Issues ``COMMIT`` on the underlying connection.
|
||||
|
||||
Args:
|
||||
message: Optional log message.
|
||||
|
||||
Returns:
|
||||
A :class:`CommitResult` describing the outcome.
|
||||
|
||||
Raises:
|
||||
SandboxCommitError: If the COMMIT fails.
|
||||
SandboxStateError: If sandbox is not in a committable status.
|
||||
"""
|
||||
if self._status not in (
|
||||
SandboxStatus.CREATED,
|
||||
SandboxStatus.ACTIVE,
|
||||
):
|
||||
raise SandboxStateError(f"Cannot commit from status {self._status.value}")
|
||||
|
||||
SandboxStatus.assert_transition(self._status, SandboxStatus.COMMITTED)
|
||||
|
||||
try:
|
||||
if self._connection is not None:
|
||||
self._connection.execute("COMMIT")
|
||||
except sqlite3.Error as exc:
|
||||
self._status = SandboxStatus.ERRORED
|
||||
raise SandboxCommitError(
|
||||
f"Failed to commit transaction for sandbox {self._sandbox_id}: {exc}"
|
||||
) from exc
|
||||
|
||||
self._status = SandboxStatus.COMMITTED
|
||||
|
||||
logger.info(
|
||||
"Committed transaction sandbox: sandbox_id=%s message=%s",
|
||||
self._sandbox_id,
|
||||
message,
|
||||
)
|
||||
|
||||
return CommitResult(
|
||||
sandbox_id=self._sandbox_id,
|
||||
success=True,
|
||||
commit_ref=None,
|
||||
changed_files=[],
|
||||
added_files=[],
|
||||
deleted_files=[],
|
||||
error=None,
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
|
||||
def rollback(self) -> None:
|
||||
"""Roll back the database transaction.
|
||||
|
||||
Issues ``ROLLBACK`` on the underlying connection and begins a
|
||||
new transaction so the sandbox can be re-used.
|
||||
|
||||
Raises:
|
||||
SandboxRollbackError: If the ROLLBACK fails.
|
||||
SandboxStateError: If called in an invalid status.
|
||||
"""
|
||||
if self._status != SandboxStatus.ACTIVE:
|
||||
raise SandboxStateError(f"Cannot rollback from status {self._status.value}")
|
||||
|
||||
SandboxStatus.assert_transition(self._status, SandboxStatus.ROLLED_BACK)
|
||||
|
||||
try:
|
||||
if self._connection is not None:
|
||||
self._connection.execute("ROLLBACK")
|
||||
# Begin a new transaction so the sandbox can be reused
|
||||
self._connection.execute("BEGIN")
|
||||
except sqlite3.Error as exc:
|
||||
self._status = SandboxStatus.ERRORED
|
||||
raise SandboxRollbackError(
|
||||
f"Failed to rollback transaction for sandbox {self._sandbox_id}: {exc}"
|
||||
) from exc
|
||||
|
||||
self._status = SandboxStatus.ROLLED_BACK
|
||||
|
||||
logger.info(
|
||||
"Rolled back transaction sandbox: sandbox_id=%s",
|
||||
self._sandbox_id,
|
||||
)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Close the database connection.
|
||||
|
||||
Attempts a ROLLBACK before closing to discard any uncommitted
|
||||
changes. Idempotent -- safe to call multiple times.
|
||||
|
||||
Raises:
|
||||
SandboxError: On unexpected errors during cleanup.
|
||||
"""
|
||||
if self._status == SandboxStatus.CLEANED_UP:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Cleaning up transaction sandbox: sandbox_id=%s path=%s",
|
||||
self._sandbox_id,
|
||||
self._original_path,
|
||||
)
|
||||
|
||||
if self._connection is not None:
|
||||
with contextlib.suppress(sqlite3.Error):
|
||||
# Discard any uncommitted work
|
||||
if self._status in (
|
||||
SandboxStatus.CREATED,
|
||||
SandboxStatus.ACTIVE,
|
||||
SandboxStatus.ROLLED_BACK,
|
||||
):
|
||||
self._connection.execute("ROLLBACK")
|
||||
with contextlib.suppress(sqlite3.Error):
|
||||
self._connection.close()
|
||||
self._connection = None
|
||||
|
||||
self._status = SandboxStatus.CLEANED_UP
|
||||
|
||||
logger.info(
|
||||
"Cleaned up transaction sandbox: sandbox_id=%s",
|
||||
self._sandbox_id,
|
||||
)
|
||||
|
||||
# -- database helper methods ---------------------------------------------
|
||||
|
||||
def execute(
|
||||
self, sql: str, params: tuple[object, ...] = ()
|
||||
) -> list[tuple[object, ...]]:
|
||||
"""Execute a SQL statement within the sandbox transaction.
|
||||
|
||||
This is a convenience method for database sandbox usage.
|
||||
It is not part of the Sandbox protocol.
|
||||
|
||||
Args:
|
||||
sql: SQL statement to execute.
|
||||
params: Bind parameters.
|
||||
|
||||
Returns:
|
||||
List of result rows (empty for non-SELECT statements).
|
||||
|
||||
Raises:
|
||||
SandboxStateError: If sandbox is not in a usable status.
|
||||
sqlite3.Error: If the SQL execution fails.
|
||||
"""
|
||||
if self._status not in (
|
||||
SandboxStatus.CREATED,
|
||||
SandboxStatus.ACTIVE,
|
||||
):
|
||||
raise SandboxStateError(
|
||||
f"Cannot execute SQL in status {self._status.value}"
|
||||
)
|
||||
|
||||
if self._status == SandboxStatus.CREATED:
|
||||
self._status = SandboxStatus.ACTIVE
|
||||
|
||||
if self._connection is None:
|
||||
raise SandboxStateError("Database connection not available")
|
||||
|
||||
cursor = self._connection.execute(sql, params)
|
||||
rows: list[tuple[object, ...]] = cursor.fetchall()
|
||||
return rows
|
||||
@@ -27,6 +27,7 @@ Handler strings stored on :class:`ResourceTypeSpec` use the format
|
||||
dynamically imports the module and returns an instance.
|
||||
"""
|
||||
|
||||
from cleveragents.resource.handlers.database import DatabaseResourceHandler
|
||||
from cleveragents.resource.handlers.devcontainer import DevcontainerHandler
|
||||
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
|
||||
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
|
||||
@@ -37,6 +38,7 @@ from cleveragents.resource.handlers.resolver import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DatabaseResourceHandler",
|
||||
"DevcontainerHandler",
|
||||
"FsDirectoryHandler",
|
||||
"GitCheckoutHandler",
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Database resource handler for CleverAgents.
|
||||
|
||||
Resolves database resources (``postgres``, ``mysql``, ``sqlite``,
|
||||
``duckdb``) into sandbox-backed :class:`BoundResource` instances
|
||||
using the ``transaction_rollback`` sandbox strategy.
|
||||
|
||||
Each database type defines its own connection arguments (host, port,
|
||||
database, user, password for networked; path for file-based).
|
||||
Authentication can be supplied directly in config or via environment
|
||||
variables.
|
||||
|
||||
Connection validation tests connectivity and returns safe error
|
||||
messages that mask credentials using the shared
|
||||
:mod:`cleveragents.shared.redaction` module.
|
||||
|
||||
Based on:
|
||||
- implementation_plan.md group M7.post-resource-db
|
||||
- Built-in type definitions for database resources
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from cleveragents.domain.models.core.resource import SandboxStrategy
|
||||
from cleveragents.resource.handlers._base import BaseResourceHandler
|
||||
from cleveragents.shared.redaction import mask_database_url, redact_dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in database resource type definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
POSTGRES_TYPE_DEF: dict[str, Any] = {
|
||||
"name": "postgres",
|
||||
"description": "PostgreSQL database connection.",
|
||||
"resource_kind": "physical",
|
||||
"sandbox_strategy": "transaction_rollback",
|
||||
"user_addable": True,
|
||||
"built_in": True,
|
||||
"cli_args": [
|
||||
{
|
||||
"name": "connection-string",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": (
|
||||
"Full connection string "
|
||||
"(e.g. postgresql://user:pass@host:5432/db). "
|
||||
"Overrides individual args."
|
||||
),
|
||||
"default": None,
|
||||
},
|
||||
{
|
||||
"name": "host",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": "Database server hostname.",
|
||||
"default": "localhost",
|
||||
},
|
||||
{
|
||||
"name": "port",
|
||||
"type": "integer",
|
||||
"required": False,
|
||||
"description": "Database server port.",
|
||||
"default": 5432,
|
||||
},
|
||||
{
|
||||
"name": "dbname",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": "Database name.",
|
||||
"default": None,
|
||||
},
|
||||
{
|
||||
"name": "user",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": (
|
||||
"Database user. Can also be set via CLEVERAGENTS_DB_USER env var."
|
||||
),
|
||||
"default": None,
|
||||
},
|
||||
{
|
||||
"name": "password",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": (
|
||||
"Database password. Can also be set via "
|
||||
"CLEVERAGENTS_DB_PASSWORD env var."
|
||||
),
|
||||
"default": None,
|
||||
},
|
||||
],
|
||||
"parent_types": [],
|
||||
"child_types": [],
|
||||
"handler": "cleveragents.resource.handlers.database:DatabaseResourceHandler",
|
||||
"capabilities": {
|
||||
"read": True,
|
||||
"write": True,
|
||||
"sandbox": True,
|
||||
"checkpoint": False,
|
||||
},
|
||||
}
|
||||
|
||||
MYSQL_TYPE_DEF: dict[str, Any] = {
|
||||
"name": "mysql",
|
||||
"description": "MySQL database connection.",
|
||||
"resource_kind": "physical",
|
||||
"sandbox_strategy": "transaction_rollback",
|
||||
"user_addable": True,
|
||||
"built_in": True,
|
||||
"cli_args": [
|
||||
{
|
||||
"name": "connection-string",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": (
|
||||
"Full connection string "
|
||||
"(e.g. mysql://user:pass@host:3306/db). "
|
||||
"Overrides individual args."
|
||||
),
|
||||
"default": None,
|
||||
},
|
||||
{
|
||||
"name": "host",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": "Database server hostname.",
|
||||
"default": "localhost",
|
||||
},
|
||||
{
|
||||
"name": "port",
|
||||
"type": "integer",
|
||||
"required": False,
|
||||
"description": "Database server port.",
|
||||
"default": 3306,
|
||||
},
|
||||
{
|
||||
"name": "dbname",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": "Database name.",
|
||||
"default": None,
|
||||
},
|
||||
{
|
||||
"name": "user",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": (
|
||||
"Database user. Can also be set via CLEVERAGENTS_DB_USER env var."
|
||||
),
|
||||
"default": None,
|
||||
},
|
||||
{
|
||||
"name": "password",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": (
|
||||
"Database password. Can also be set via "
|
||||
"CLEVERAGENTS_DB_PASSWORD env var."
|
||||
),
|
||||
"default": None,
|
||||
},
|
||||
],
|
||||
"parent_types": [],
|
||||
"child_types": [],
|
||||
"handler": "cleveragents.resource.handlers.database:DatabaseResourceHandler",
|
||||
"capabilities": {
|
||||
"read": True,
|
||||
"write": True,
|
||||
"sandbox": True,
|
||||
"checkpoint": False,
|
||||
},
|
||||
}
|
||||
|
||||
SQLITE_TYPE_DEF: dict[str, Any] = {
|
||||
"name": "sqlite",
|
||||
"description": "SQLite database file.",
|
||||
"resource_kind": "physical",
|
||||
"sandbox_strategy": "transaction_rollback",
|
||||
"user_addable": True,
|
||||
"built_in": True,
|
||||
"cli_args": [
|
||||
{
|
||||
"name": "path",
|
||||
"type": "path",
|
||||
"required": True,
|
||||
"description": (
|
||||
"Path to the SQLite database file (use :memory: for in-memory)."
|
||||
),
|
||||
},
|
||||
],
|
||||
"parent_types": ["fs-directory", "git-checkout"],
|
||||
"child_types": [],
|
||||
"handler": "cleveragents.resource.handlers.database:DatabaseResourceHandler",
|
||||
"capabilities": {
|
||||
"read": True,
|
||||
"write": True,
|
||||
"sandbox": True,
|
||||
"checkpoint": False,
|
||||
},
|
||||
}
|
||||
|
||||
DUCKDB_TYPE_DEF: dict[str, Any] = {
|
||||
"name": "duckdb",
|
||||
"description": "DuckDB database (file-based or in-memory).",
|
||||
"resource_kind": "physical",
|
||||
"sandbox_strategy": "transaction_rollback",
|
||||
"user_addable": True,
|
||||
"built_in": True,
|
||||
"cli_args": [
|
||||
{
|
||||
"name": "path",
|
||||
"type": "path",
|
||||
"required": False,
|
||||
"description": (
|
||||
"Path to the DuckDB database file. Omit or use :memory: for in-memory."
|
||||
),
|
||||
"default": ":memory:",
|
||||
},
|
||||
],
|
||||
"parent_types": ["fs-directory", "git-checkout"],
|
||||
"child_types": [],
|
||||
"handler": "cleveragents.resource.handlers.database:DatabaseResourceHandler",
|
||||
"capabilities": {
|
||||
"read": True,
|
||||
"write": True,
|
||||
"sandbox": True,
|
||||
"checkpoint": False,
|
||||
},
|
||||
}
|
||||
|
||||
#: All database type definitions in registration order.
|
||||
DATABASE_TYPE_DEFS: list[dict[str, Any]] = [
|
||||
POSTGRES_TYPE_DEF,
|
||||
MYSQL_TYPE_DEF,
|
||||
SQLITE_TYPE_DEF,
|
||||
DUCKDB_TYPE_DEF,
|
||||
]
|
||||
|
||||
#: Database type names.
|
||||
DATABASE_TYPE_NAMES: frozenset[str] = frozenset(
|
||||
{"postgres", "mysql", "sqlite", "duckdb"}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection argument resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_connection_args(
|
||||
properties: dict[str, str | int | float | bool | None],
|
||||
resource_type: str,
|
||||
) -> dict[str, str | int | None]:
|
||||
"""Resolve connection arguments from properties and env vars.
|
||||
|
||||
For networked databases (postgres, mysql), credentials can come
|
||||
from environment variables ``CLEVERAGENTS_DB_USER`` and
|
||||
``CLEVERAGENTS_DB_PASSWORD`` when not set in properties.
|
||||
|
||||
Args:
|
||||
properties: Resource properties dict.
|
||||
resource_type: The database resource type name.
|
||||
|
||||
Returns:
|
||||
Resolved connection arguments.
|
||||
"""
|
||||
args: dict[str, str | int | None] = {}
|
||||
|
||||
if resource_type in ("postgres", "mysql"):
|
||||
conn_str = properties.get("connection-string")
|
||||
if conn_str is not None:
|
||||
args["connection-string"] = str(conn_str)
|
||||
return args
|
||||
|
||||
args["host"] = str(properties.get("host", "localhost") or "localhost")
|
||||
default_port = 5432 if resource_type == "postgres" else 3306
|
||||
raw_port = properties.get("port", default_port)
|
||||
args["port"] = int(raw_port) if raw_port is not None else default_port
|
||||
args["dbname"] = str(properties["dbname"]) if properties.get("dbname") else None
|
||||
|
||||
# User from properties or env
|
||||
user = properties.get("user")
|
||||
if user is None:
|
||||
user = os.environ.get("CLEVERAGENTS_DB_USER")
|
||||
args["user"] = str(user) if user else None
|
||||
|
||||
# Password from properties or env
|
||||
password = properties.get("password")
|
||||
if password is None:
|
||||
password = os.environ.get("CLEVERAGENTS_DB_PASSWORD")
|
||||
args["password"] = str(password) if password else None
|
||||
|
||||
elif resource_type in ("sqlite", "duckdb"):
|
||||
path = properties.get("path", ":memory:")
|
||||
args["path"] = str(path) if path is not None else ":memory:"
|
||||
|
||||
return args
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def validate_connection(
|
||||
resource_type: str,
|
||||
connection_args: dict[str, str | int | None],
|
||||
) -> tuple[bool, str]:
|
||||
"""Test database connectivity and return a safe status message.
|
||||
|
||||
Only SQLite is fully implemented (uses stdlib ``sqlite3``).
|
||||
PostgreSQL, MySQL, and DuckDB return stub messages indicating
|
||||
the driver is not available.
|
||||
|
||||
Error messages mask credentials using the shared redaction module.
|
||||
|
||||
Args:
|
||||
resource_type: Database type name.
|
||||
connection_args: Resolved connection arguments.
|
||||
|
||||
Returns:
|
||||
Tuple of ``(success, message)``.
|
||||
"""
|
||||
if resource_type == "sqlite":
|
||||
return _validate_sqlite(connection_args)
|
||||
if resource_type == "duckdb":
|
||||
return _validate_duckdb(connection_args)
|
||||
if resource_type in ("postgres", "mysql"):
|
||||
return _validate_network_db(resource_type, connection_args)
|
||||
|
||||
return (False, f"Unknown database type: {resource_type}")
|
||||
|
||||
|
||||
def _validate_sqlite(
|
||||
args: dict[str, str | int | None],
|
||||
) -> tuple[bool, str]:
|
||||
"""Validate an SQLite connection."""
|
||||
path = str(args.get("path", ":memory:") or ":memory:")
|
||||
try:
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("SELECT 1")
|
||||
conn.close()
|
||||
return (True, f"SQLite connection OK: {path}")
|
||||
except Exception as exc:
|
||||
return (False, f"SQLite connection failed: {exc}")
|
||||
|
||||
|
||||
def _validate_duckdb(
|
||||
args: dict[str, str | int | None],
|
||||
) -> tuple[bool, str]:
|
||||
"""Validate a DuckDB connection (stub -- driver not bundled)."""
|
||||
path = str(args.get("path", ":memory:") or ":memory:")
|
||||
try:
|
||||
import importlib
|
||||
|
||||
_duckdb = importlib.import_module("duckdb")
|
||||
conn = _duckdb.connect(path)
|
||||
conn.execute("SELECT 1")
|
||||
conn.close()
|
||||
return (True, f"DuckDB connection OK: {path}")
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
return (
|
||||
True,
|
||||
f"DuckDB driver not installed; config accepted for: {path}",
|
||||
)
|
||||
except Exception as exc:
|
||||
return (False, f"DuckDB connection failed: {exc}")
|
||||
|
||||
|
||||
def _validate_network_db(
|
||||
resource_type: str,
|
||||
args: dict[str, str | int | None],
|
||||
) -> tuple[bool, str]:
|
||||
"""Validate a networked DB connection (stub -- drivers not bundled).
|
||||
|
||||
Credential values are redacted in the returned message.
|
||||
"""
|
||||
safe_args = redact_dict(
|
||||
{k: v for k, v in args.items() if v is not None},
|
||||
)
|
||||
conn_str = args.get("connection-string")
|
||||
if conn_str is not None:
|
||||
masked = mask_database_url(str(conn_str))
|
||||
return (
|
||||
True,
|
||||
(f"{resource_type} driver not installed; config accepted for: {masked}"),
|
||||
)
|
||||
|
||||
return (
|
||||
True,
|
||||
(f"{resource_type} driver not installed; config accepted: {safe_args}"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DatabaseResourceHandler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DatabaseResourceHandler(BaseResourceHandler):
|
||||
"""Handler for database resource types.
|
||||
|
||||
Provisions a transaction-rollback sandbox for database resources.
|
||||
Supports ``postgres``, ``mysql``, ``sqlite``, and ``duckdb``.
|
||||
"""
|
||||
|
||||
_default_strategy = SandboxStrategy.TRANSACTION_ROLLBACK
|
||||
_type_label = "database"
|
||||
@@ -942,6 +942,20 @@ max_iterations # noqa: B018, F821
|
||||
_temporal_score # noqa: B018, F821
|
||||
_extract_node_prefix # noqa: B018, F821
|
||||
|
||||
# Database resource handler — public API (issue #342)
|
||||
DatabaseResourceHandler # noqa: B018, F821
|
||||
DATABASE_TYPE_DEFS # noqa: B018, F821
|
||||
DATABASE_TYPE_NAMES # noqa: B018, F821
|
||||
POSTGRES_TYPE_DEF # noqa: B018, F821
|
||||
MYSQL_TYPE_DEF # noqa: B018, F821
|
||||
SQLITE_TYPE_DEF # noqa: B018, F821
|
||||
DUCKDB_TYPE_DEF # noqa: B018, F821
|
||||
resolve_connection_args # noqa: B018, F821
|
||||
validate_connection # noqa: B018, F821
|
||||
|
||||
# Transaction sandbox — public API (issue #342)
|
||||
TransactionSandbox # noqa: B018, F821
|
||||
|
||||
# ComponentResolver — pluggable scope chain resolution (#552)
|
||||
ComponentResolver # noqa: B018, F821
|
||||
ComponentNotFoundError # noqa: B018, F821
|
||||
|
||||
Reference in New Issue
Block a user