|
|
@@ -0,0 +1,1782 @@
|
|
|
+# Data Source Connection Pool Implementation Plan
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** Build a secure, lazy, per-data-source connection pool framework for PostgreSQL and MySQL while keeping the platform PostgreSQL connection path separate.
|
|
|
+
|
|
|
+**Architecture:** Flask-SQLAlchemy continues to own only the platform control database. External `DataSource` definitions are resolved through a `DataSourceConnectionManager`, which loads encrypted credentials from PostgreSQL, selects an allowlisted adapter, and acquires a connection from a worker-local lazy pool registry. Credential/configuration version changes invalidate old pools; per-source circuit breakers and diagnostics isolate failures.
|
|
|
+
|
|
|
+**Tech Stack:** Flask 2.3, Flask-SQLAlchemy 3.1, SQLAlchemy 2.0 `QueuePool`/`NullPool`, PostgreSQL, MySQL 8, Neo4j, AES-256-GCM from `cryptography`, psycopg2, PyMySQL, Alembic, pytest, Docker Compose.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File and responsibility map
|
|
|
+
|
|
|
+| File | Responsibility |
|
|
|
+|---|---|
|
|
|
+| `app/core/data_source/errors.py` | Stable data-source error classes and safe API codes |
|
|
|
+| `app/core/data_source/redaction.py` | Recursive request and exception redaction |
|
|
|
+| `app/core/data_source/models.py` | Immutable definitions, credentials and pool-status value objects |
|
|
|
+| `app/core/data_source/credentials.py` | AES-GCM codec and PostgreSQL credential repository |
|
|
|
+| `app/core/data_source/definitions.py` | Neo4j `DataSource` definition repository without secrets |
|
|
|
+| `app/core/data_source/adapters/base.py` | Adapter protocol and allowlisted common options |
|
|
|
+| `app/core/data_source/adapters/postgresql.py` | PostgreSQL Engine and connection probe |
|
|
|
+| `app/core/data_source/adapters/mysql.py` | MySQL Engine and connection probe |
|
|
|
+| `app/core/data_source/circuit_breaker.py` | Per-source closed/open/half-open state machine |
|
|
|
+| `app/core/data_source/pool_registry.py` | Worker-local Engine registry, leases, draining and idle eviction |
|
|
|
+| `app/core/data_source/manager.py` | Public `connect(uid, purpose)` context manager |
|
|
|
+| `app/core/data_source/runtime.py` | One manager instance per Gunicorn worker |
|
|
|
+| `app/core/data_source/service.py` | Save/update/delete/test orchestration across PostgreSQL and Neo4j |
|
|
|
+| `app/api/data_source/routes.py` | HTTP boundary; no direct Engine or credential access |
|
|
|
+| `app/commands/report_datasource_credentials.py` | Read-only legacy credential audit |
|
|
|
+| `app/commands/migrate_datasource_credentials.py` | Explicit encryption/backfill and plaintext removal |
|
|
|
+| `app/commands/reconcile_datasource_credentials.py` | Cross-store orphan/stale reference report and repair |
|
|
|
+| `migrations/versions/20260718_50_datasource_credentials.py` | Encrypted credential and audit schema |
|
|
|
+| `tests/core/data_source/` | Unit and contract tests |
|
|
|
+| `tests/integration/test_datasource_pools.py` | PostgreSQL/MySQL pool integration |
|
|
|
+| `tests/integration/test_datasource_pool_failures.py` | Outage, recovery and invalidation acceptance |
|
|
|
+| `frontend/src/views/dataGovernance/dataSource/` | Secret-free PostgreSQL/MySQL management UI |
|
|
|
+
|
|
|
+## Task 1: Add dependencies and environment contract
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `requirements.txt`
|
|
|
+- Modify: `app/config/config.py`
|
|
|
+- Modify: `deploy/docker/.env.example`
|
|
|
+- Modify: `deployment/dataops.env`
|
|
|
+- Test: `tests/core/data_source/test_config.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write the failing configuration test**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_datasource_pool_defaults_are_bounded(monkeypatch):
|
|
|
+ monkeypatch.delenv("DATASOURCE_POOL_SIZE", raising=False)
|
|
|
+ monkeypatch.delenv("DATASOURCE_MAX_OVERFLOW", raising=False)
|
|
|
+ from app.config.config import BaseConfig
|
|
|
+
|
|
|
+ assert BaseConfig.DATASOURCE_POOL_SIZE == 2
|
|
|
+ assert BaseConfig.DATASOURCE_MAX_OVERFLOW == 3
|
|
|
+ assert BaseConfig.DATASOURCE_POOL_TIMEOUT == 10
|
|
|
+ assert BaseConfig.DATASOURCE_POOL_RECYCLE == 1800
|
|
|
+ assert BaseConfig.DATASOURCE_POOL_IDLE_TTL == 900
|
|
|
+ assert BaseConfig.DATASOURCE_MAX_IDLE_POOLS == 20
|
|
|
+ assert BaseConfig.DATASOURCE_QUERY_TIMEOUT == 30
|
|
|
+
|
|
|
+
|
|
|
+def test_pool_limits_reject_unsafe_values(monkeypatch):
|
|
|
+ monkeypatch.setenv("DATASOURCE_POOL_SIZE", "100")
|
|
|
+ from app.config.config import datasource_pool_settings
|
|
|
+
|
|
|
+ with pytest.raises(ValueError, match="DATASOURCE_POOL_SIZE"):
|
|
|
+ datasource_pool_settings()
|
|
|
+
|
|
|
+
|
|
|
+def test_datasource_override_is_clamped_to_server_limits():
|
|
|
+ settings = datasource_pool_settings(
|
|
|
+ {"pool_size": 4, "max_overflow": 5}
|
|
|
+ )
|
|
|
+ assert settings["pool_size"] == 4
|
|
|
+ assert settings["max_overflow"] == 5
|
|
|
+
|
|
|
+ with pytest.raises(ValueError, match="pool_size"):
|
|
|
+ datasource_pool_settings({"pool_size": 11})
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run the test and confirm the red state**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest tests/core/data_source/test_config.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because the pool settings do not exist.
|
|
|
+
|
|
|
+- [ ] **Step 3: Pin the required drivers**
|
|
|
+
|
|
|
+Add:
|
|
|
+
|
|
|
+```text
|
|
|
+cryptography==43.0.3
|
|
|
+PyMySQL==1.1.1
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Add bounded environment parsing**
|
|
|
+
|
|
|
+Add to `app/config/config.py`:
|
|
|
+
|
|
|
+```python
|
|
|
+def _bounded_int(name: str, default: int, minimum: int, maximum: int) -> int:
|
|
|
+ value = int(os.environ.get(name, str(default)))
|
|
|
+ if value < minimum or value > maximum:
|
|
|
+ raise ValueError(f"{name} must be between {minimum} and {maximum}")
|
|
|
+ return value
|
|
|
+
|
|
|
+
|
|
|
+def datasource_pool_settings(overrides: Mapping[str, int] | None = None) -> dict:
|
|
|
+ settings = {
|
|
|
+ "pool_size": _bounded_int("DATASOURCE_POOL_SIZE", 2, 1, 10),
|
|
|
+ "max_overflow": _bounded_int("DATASOURCE_MAX_OVERFLOW", 3, 0, 10),
|
|
|
+ "pool_timeout": _bounded_int("DATASOURCE_POOL_TIMEOUT", 10, 1, 60),
|
|
|
+ "pool_recycle": _bounded_int("DATASOURCE_POOL_RECYCLE", 1800, 60, 86400),
|
|
|
+ "idle_ttl": _bounded_int("DATASOURCE_POOL_IDLE_TTL", 900, 60, 86400),
|
|
|
+ "max_idle_pools": _bounded_int("DATASOURCE_MAX_IDLE_POOLS", 20, 1, 100),
|
|
|
+ "query_timeout": _bounded_int("DATASOURCE_QUERY_TIMEOUT", 30, 1, 300),
|
|
|
+ }
|
|
|
+ return apply_allowlisted_pool_overrides(settings, overrides or {})
|
|
|
+```
|
|
|
+
|
|
|
+Expose the same values on `BaseConfig`, plus:
|
|
|
+
|
|
|
+```python
|
|
|
+DATASOURCE_CREDENTIAL_MASTER_KEY = os.environ.get(
|
|
|
+ "DATASOURCE_CREDENTIAL_MASTER_KEY", ""
|
|
|
+)
|
|
|
+DATASOURCE_CREDENTIAL_KEY_VERSION = os.environ.get(
|
|
|
+ "DATASOURCE_CREDENTIAL_KEY_VERSION", "v1"
|
|
|
+)
|
|
|
+DATASOURCE_CERT_DIR = os.environ.get(
|
|
|
+ "DATASOURCE_CERT_DIR", "/etc/dataops-platform/datasource-certs"
|
|
|
+)
|
|
|
+```
|
|
|
+
|
|
|
+Only `pool_size` and `max_overflow` may be overridden per data source in phase
|
|
|
+one. Reject unknown keys and values outside the same server-side bounds; never
|
|
|
+accept `pool_timeout`, driver names or raw `connect_args` from an API request.
|
|
|
+
|
|
|
+- [ ] **Step 5: Document environment variables without real secrets**
|
|
|
+
|
|
|
+Add template values:
|
|
|
+
|
|
|
+```text
|
|
|
+DATASOURCE_CREDENTIAL_MASTER_KEY=replace-with-base64-32-byte-key
|
|
|
+DATASOURCE_CREDENTIAL_KEY_VERSION=v1
|
|
|
+DATASOURCE_POOL_SIZE=2
|
|
|
+DATASOURCE_MAX_OVERFLOW=3
|
|
|
+DATASOURCE_POOL_TIMEOUT=10
|
|
|
+DATASOURCE_POOL_RECYCLE=1800
|
|
|
+DATASOURCE_POOL_IDLE_TTL=900
|
|
|
+DATASOURCE_MAX_IDLE_POOLS=20
|
|
|
+DATASOURCE_QUERY_TIMEOUT=30
|
|
|
+DATASOURCE_CERT_DIR=/etc/dataops-platform/datasource-certs
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Run tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest tests/core/data_source/test_config.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add requirements.txt app/config/config.py deploy/docker/.env.example \
|
|
|
+ deployment/dataops.env tests/core/data_source/test_config.py
|
|
|
+git commit -m "feat: define external datasource pool settings"
|
|
|
+```
|
|
|
+
|
|
|
+## Task 2: Create the encrypted credential schema
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `migrations/versions/20260718_50_datasource_credentials.py`
|
|
|
+- Create: `tests/core/data_source/test_credential_schema.py`
|
|
|
+- Modify: `tests/test_database_migrations.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write the failing migration contract test**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_datasource_credential_migration_is_encrypted_and_versioned():
|
|
|
+ source = Path(
|
|
|
+ "migrations/versions/20260718_50_datasource_credentials.py"
|
|
|
+ ).read_text(encoding="utf-8")
|
|
|
+
|
|
|
+ assert "datasource_credentials" in source
|
|
|
+ assert "encrypted_payload" in source
|
|
|
+ assert "nonce" in source
|
|
|
+ assert "key_version" in source
|
|
|
+ assert "credential_version" in source
|
|
|
+ assert "UNIQUE (data_source_uid, credential_version)" in source
|
|
|
+ assert "password VARCHAR" not in source
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run the test and confirm failure**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_credential_schema.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because the migration does not exist.
|
|
|
+
|
|
|
+- [ ] **Step 3: Add the non-destructive migration**
|
|
|
+
|
|
|
+Use `down_revision = "20260716_40"` and create:
|
|
|
+
|
|
|
+```sql
|
|
|
+CREATE TABLE IF NOT EXISTS public.datasource_credentials (
|
|
|
+ id UUID PRIMARY KEY,
|
|
|
+ data_source_uid UUID NOT NULL,
|
|
|
+ credential_version INTEGER NOT NULL CHECK (credential_version > 0),
|
|
|
+ encrypted_payload BYTEA NOT NULL,
|
|
|
+ nonce BYTEA NOT NULL CHECK (octet_length(nonce) = 12),
|
|
|
+ key_version VARCHAR(40) NOT NULL,
|
|
|
+ status VARCHAR(20) NOT NULL DEFAULT 'active'
|
|
|
+ CHECK (status IN ('active', 'retired', 'revoked')),
|
|
|
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ retired_at TIMESTAMPTZ,
|
|
|
+ UNIQUE (data_source_uid, credential_version)
|
|
|
+);
|
|
|
+
|
|
|
+CREATE UNIQUE INDEX IF NOT EXISTS uq_datasource_credential_active
|
|
|
+ON public.datasource_credentials(data_source_uid)
|
|
|
+WHERE status = 'active';
|
|
|
+```
|
|
|
+
|
|
|
+Also create `datasource_credential_audit_events` with event type, UID, version,
|
|
|
+actor UID, success flag, safe detail and timestamp. Do not create password,
|
|
|
+connection URL or raw request columns.
|
|
|
+
|
|
|
+Keep `downgrade()` data-preserving, matching the current migration policy.
|
|
|
+
|
|
|
+- [ ] **Step 4: Extend disposable-database migration verification**
|
|
|
+
|
|
|
+Add `datasource_credentials` and `datasource_credential_audit_events` to the
|
|
|
+post-upgrade expected table set, not to the original baseline migration set.
|
|
|
+
|
|
|
+- [ ] **Step 5: Run schema tests**
|
|
|
+
|
|
|
+```bash
|
|
|
+TEST_POSTGRES_ADMIN_URL=postgresql://dataops:dataops-test-password@localhost:15432/postgres \
|
|
|
+ .venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_credential_schema.py \
|
|
|
+ tests/test_database_migrations.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: all tests PASS; temporary database is dropped.
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add migrations/versions/20260718_50_datasource_credentials.py \
|
|
|
+ tests/core/data_source/test_credential_schema.py \
|
|
|
+ tests/test_database_migrations.py
|
|
|
+git commit -m "feat: add encrypted datasource credentials schema"
|
|
|
+```
|
|
|
+
|
|
|
+## Task 3: Implement AES-GCM credential handling
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `app/core/data_source/__init__.py`
|
|
|
+- Create: `app/core/data_source/errors.py`
|
|
|
+- Create: `app/core/data_source/models.py`
|
|
|
+- Create: `app/core/data_source/credentials.py`
|
|
|
+- Test: `tests/core/data_source/test_credentials.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing codec tests**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_credential_round_trip_and_tamper_detection():
|
|
|
+ codec = CredentialCodec(
|
|
|
+ master_key=b"k" * 32,
|
|
|
+ key_version="v1",
|
|
|
+ )
|
|
|
+ sealed = codec.encrypt(
|
|
|
+ data_source_uid="01900000-0000-7000-8000-000000000010",
|
|
|
+ credential_version=1,
|
|
|
+ credential=DataSourceCredential("reader", "secret"),
|
|
|
+ )
|
|
|
+
|
|
|
+ assert b"secret" not in sealed.encrypted_payload
|
|
|
+ assert codec.decrypt(sealed) == DataSourceCredential("reader", "secret")
|
|
|
+
|
|
|
+ tampered = dataclasses.replace(
|
|
|
+ sealed,
|
|
|
+ encrypted_payload=sealed.encrypted_payload[:-1] + b"x",
|
|
|
+ )
|
|
|
+ with pytest.raises(DataSourceCredentialUnavailable):
|
|
|
+ codec.decrypt(tampered)
|
|
|
+
|
|
|
+
|
|
|
+def test_credential_cannot_be_replayed_for_another_datasource():
|
|
|
+ sealed = codec.encrypt(DATA_SOURCE_A, 1, credential)
|
|
|
+ replayed = dataclasses.replace(sealed, data_source_uid=DATA_SOURCE_B)
|
|
|
+
|
|
|
+ with pytest.raises(DataSourceCredentialUnavailable):
|
|
|
+ codec.decrypt(replayed)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Verify tests fail**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_credentials.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL with missing modules.
|
|
|
+
|
|
|
+- [ ] **Step 3: Define immutable value objects and stable errors**
|
|
|
+
|
|
|
+```python
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DataSourceCredential:
|
|
|
+ username: str
|
|
|
+ password: str
|
|
|
+ options: Mapping[str, str] = field(default_factory=dict)
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class SealedCredential:
|
|
|
+ id: str
|
|
|
+ data_source_uid: str
|
|
|
+ credential_version: int
|
|
|
+ encrypted_payload: bytes
|
|
|
+ nonce: bytes
|
|
|
+ key_version: str
|
|
|
+ status: str = "active"
|
|
|
+```
|
|
|
+
|
|
|
+Define `DataSourceError` subclasses for every stable phase-one code:
|
|
|
+
|
|
|
+```python
|
|
|
+class DataSourceCredentialUnavailable(DataSourceError):
|
|
|
+ code = "DATASOURCE_CREDENTIAL_UNAVAILABLE"
|
|
|
+ http_status = 503
|
|
|
+```
|
|
|
+
|
|
|
+The complete set is `DATASOURCE_NOT_FOUND`,
|
|
|
+`DATASOURCE_TYPE_UNSUPPORTED`, `DATASOURCE_CONFIGURATION_INVALID`,
|
|
|
+`DATASOURCE_CREDENTIAL_UNAVAILABLE`, `DATASOURCE_CONNECTION_FAILED`,
|
|
|
+`DATASOURCE_POOL_TIMEOUT`, `DATASOURCE_CIRCUIT_OPEN`,
|
|
|
+`DATASOURCE_QUERY_TIMEOUT`, and `DATASOURCE_READ_ONLY_VIOLATION`. Safe public
|
|
|
+messages must not include DBAPI exception text.
|
|
|
+
|
|
|
+- [ ] **Step 4: Implement AES-256-GCM**
|
|
|
+
|
|
|
+Use:
|
|
|
+
|
|
|
+```python
|
|
|
+aad = f"{data_source_uid}:{credential_version}".encode("utf-8")
|
|
|
+nonce = os.urandom(12)
|
|
|
+payload = json.dumps(
|
|
|
+ {
|
|
|
+ "username": credential.username,
|
|
|
+ "password": credential.password,
|
|
|
+ "options": dict(credential.options),
|
|
|
+ },
|
|
|
+ sort_keys=True,
|
|
|
+ separators=(",", ":"),
|
|
|
+).encode("utf-8")
|
|
|
+ciphertext = AESGCM(master_key).encrypt(nonce, payload, aad)
|
|
|
+```
|
|
|
+
|
|
|
+Decode the environment key with `base64.urlsafe_b64decode`; require exactly
|
|
|
+32 bytes. Convert `InvalidTag`, malformed JSON and key mismatch to
|
|
|
+`DataSourceCredentialUnavailable("credential cannot be decrypted")`.
|
|
|
+
|
|
|
+- [ ] **Step 5: Add repository create/get/retire/revoke methods**
|
|
|
+
|
|
|
+Repository methods accept a SQLAlchemy Session and use parameterized `text()`.
|
|
|
+`create_version()` takes an advisory transaction lock on `data_source_uid`,
|
|
|
+increments the version and retires the prior active row in the same transaction.
|
|
|
+
|
|
|
+- [ ] **Step 6: Run tests**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_credentials.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add app/core/data_source tests/core/data_source/test_credentials.py
|
|
|
+git commit -m "feat: encrypt external datasource credentials"
|
|
|
+```
|
|
|
+
|
|
|
+## Task 4: Remove secrets from logs and API serialization
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `app/core/data_source/redaction.py`
|
|
|
+- Modify: `app/api/data_source/routes.py`
|
|
|
+- Test: `tests/core/data_source/test_redaction.py`
|
|
|
+- Test: `tests/test_datasource_api_security.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing redaction tests**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_recursive_redaction_covers_connection_secrets():
|
|
|
+ value = {
|
|
|
+ "name": "warehouse",
|
|
|
+ "password": "plain",
|
|
|
+ "options": {
|
|
|
+ "api_key": "key",
|
|
|
+ "url": "postgresql://user:secret@db:5432/app",
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ assert redact_mapping(value) == {
|
|
|
+ "name": "warehouse",
|
|
|
+ "password": "[redacted]",
|
|
|
+ "options": {
|
|
|
+ "api_key": "[redacted]",
|
|
|
+ "url": "postgresql://[redacted]@db:5432/app",
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def test_datasource_list_never_serializes_credentials(client, viewer_token):
|
|
|
+ response = client.post(
|
|
|
+ "/api/datasource/list",
|
|
|
+ json={},
|
|
|
+ headers=bearer(viewer_token),
|
|
|
+ )
|
|
|
+ text = response.get_data(as_text=True).lower()
|
|
|
+
|
|
|
+ assert "password" not in text
|
|
|
+ assert "encrypted_payload" not in text
|
|
|
+ assert "credential_configured" in text
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Verify tests fail**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_redaction.py \
|
|
|
+ tests/test_datasource_api_security.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because request logging and serialization expose sensitive keys.
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement recursive redaction**
|
|
|
+
|
|
|
+Redact case-insensitive keys:
|
|
|
+
|
|
|
+```python
|
|
|
+SENSITIVE_KEYS = {
|
|
|
+ "password",
|
|
|
+ "passwd",
|
|
|
+ "credential",
|
|
|
+ "credentials",
|
|
|
+ "api_key",
|
|
|
+ "token",
|
|
|
+ "authorization",
|
|
|
+ "encrypted_payload",
|
|
|
+ "nonce",
|
|
|
+ "conn_str",
|
|
|
+ "connection_string",
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Redact URL userinfo using `urllib.parse.urlsplit()` and return a URL containing
|
|
|
+`[redacted]@host`. Truncate sanitized exception messages to 1000 characters.
|
|
|
+
|
|
|
+- [ ] **Step 4: Remove raw request logging immediately**
|
|
|
+
|
|
|
+Replace every pattern like:
|
|
|
+
|
|
|
+```python
|
|
|
+logger.debug("保存数据源请求数据: %s", json.dumps(data))
|
|
|
+```
|
|
|
+
|
|
|
+with:
|
|
|
+
|
|
|
+```python
|
|
|
+logger.debug("保存数据源请求: %s", redact_mapping(data or {}))
|
|
|
+```
|
|
|
+
|
|
|
+The list response must strip `username`, `password`, `credential_ref`,
|
|
|
+`encrypted_payload` and `nonce`; expose only:
|
|
|
+
|
|
|
+```python
|
|
|
+item["credential_configured"] = bool(item.pop("credential_version", None))
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Run security tests and source scan**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_redaction.py \
|
|
|
+ tests/test_datasource_api_security.py -q
|
|
|
+rg -n "json\\.dumps\\(data|log_data.*password|RETURN n.*password" \
|
|
|
+ app/api/data_source app/core/data_source
|
|
|
+```
|
|
|
+
|
|
|
+Expected: tests PASS and `rg` returns no unsafe logging pattern.
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add app/core/data_source/redaction.py app/api/data_source/routes.py \
|
|
|
+ tests/core/data_source/test_redaction.py \
|
|
|
+ tests/test_datasource_api_security.py
|
|
|
+git commit -m "fix: prevent datasource credential disclosure"
|
|
|
+```
|
|
|
+
|
|
|
+## Task 5: Implement the Neo4j definition repository
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `app/core/data_source/definitions.py`
|
|
|
+- Test: `tests/core/data_source/test_definitions.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing repository tests**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_definition_repository_assigns_uid_and_excludes_secrets(fake_graph):
|
|
|
+ repository = DataSourceDefinitionRepository(fake_graph)
|
|
|
+ saved = repository.save(
|
|
|
+ DataSourceDefinition(
|
|
|
+ uid=None,
|
|
|
+ name_en="warehouse",
|
|
|
+ database_type="postgresql",
|
|
|
+ host="source-postgres",
|
|
|
+ port=5432,
|
|
|
+ database="analytics",
|
|
|
+ schema="public",
|
|
|
+ credential_ref="01900000-0000-7000-8000-000000000011",
|
|
|
+ credential_version=1,
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ assert uuid.UUID(saved.uid).version == 7
|
|
|
+ assert "password" not in fake_graph.last_parameters["properties"]
|
|
|
+ assert "username" not in fake_graph.last_parameters["properties"]
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Verify failure**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_definitions.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because the repository does not exist.
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement a parameterized repository**
|
|
|
+
|
|
|
+Support:
|
|
|
+
|
|
|
+```python
|
|
|
+get(uid: str) -> DataSourceDefinition | None
|
|
|
+list(filters: DataSourceFilters) -> list[DataSourceDefinition]
|
|
|
+save(definition: DataSourceDefinition) -> DataSourceDefinition
|
|
|
+delete(uid: str) -> bool
|
|
|
+credential_references() -> dict[str, tuple[str, int]]
|
|
|
+remove_legacy_secret_fields(uid: str) -> None
|
|
|
+```
|
|
|
+
|
|
|
+Use `uid`, not Neo4j internal numeric IDs, for new writes and updates. Keep a
|
|
|
+temporary numeric-ID lookup only inside the migration command.
|
|
|
+
|
|
|
+- [ ] **Step 4: Add uniqueness constraint reconciliation**
|
|
|
+
|
|
|
+Extend `app/commands/reconcile_governance_uids.py` with:
|
|
|
+
|
|
|
+```cypher
|
|
|
+CREATE CONSTRAINT datasource_uid_unique IF NOT EXISTS
|
|
|
+FOR (node:DataSource) REQUIRE node.uid IS UNIQUE
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Run tests**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_definitions.py \
|
|
|
+ tests/test_governance_identifiers.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add app/core/data_source/definitions.py \
|
|
|
+ app/commands/reconcile_governance_uids.py \
|
|
|
+ tests/core/data_source/test_definitions.py \
|
|
|
+ tests/test_governance_identifiers.py
|
|
|
+git commit -m "feat: add secret-free datasource definitions"
|
|
|
+```
|
|
|
+
|
|
|
+## Task 6: Implement PostgreSQL and MySQL adapters
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `app/core/data_source/adapters/__init__.py`
|
|
|
+- Create: `app/core/data_source/adapters/base.py`
|
|
|
+- Create: `app/core/data_source/adapters/postgresql.py`
|
|
|
+- Create: `app/core/data_source/adapters/mysql.py`
|
|
|
+- Test: `tests/core/data_source/test_adapters.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing adapter tests**
|
|
|
+
|
|
|
+```python
|
|
|
+@pytest.mark.parametrize(
|
|
|
+ ("database_type", "drivername"),
|
|
|
+ [
|
|
|
+ ("postgresql", "postgresql+psycopg2"),
|
|
|
+ ("mysql", "mysql+pymysql"),
|
|
|
+ ],
|
|
|
+)
|
|
|
+def test_adapter_uses_allowlisted_driver(database_type, drivername):
|
|
|
+ adapter = adapter_for(database_type)
|
|
|
+ url = adapter.build_url(definition, credential)
|
|
|
+
|
|
|
+ assert url.drivername == drivername
|
|
|
+ assert url.password == "secret"
|
|
|
+
|
|
|
+
|
|
|
+def test_unknown_driver_is_rejected():
|
|
|
+ with pytest.raises(DataSourceTypeUnsupported):
|
|
|
+ adapter_for("sqlite")
|
|
|
+
|
|
|
+
|
|
|
+def test_adapter_rejects_arbitrary_connection_options():
|
|
|
+ with pytest.raises(DataSourceConfigurationInvalid):
|
|
|
+ adapter.validate_options({"driver": "custom", "connect_args": {}})
|
|
|
+
|
|
|
+
|
|
|
+def test_read_purpose_configures_database_read_only_transaction():
|
|
|
+ adapter.configure_transaction(connection, purpose="metadata_preview")
|
|
|
+ assert connection.executed == ["SET TRANSACTION READ ONLY"]
|
|
|
+
|
|
|
+
|
|
|
+def test_connection_test_uses_null_pool_and_disposes(monkeypatch):
|
|
|
+ engine = FakeEngine()
|
|
|
+ monkeypatch.setattr(base, "create_engine", lambda *a, **kw: engine)
|
|
|
+
|
|
|
+ adapter.test_connection(definition, credential)
|
|
|
+
|
|
|
+ assert engine.pool_class is NullPool
|
|
|
+ assert engine.disposed is True
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Verify failure**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_adapters.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL with missing adapter modules.
|
|
|
+
|
|
|
+- [ ] **Step 3: Define the adapter protocol**
|
|
|
+
|
|
|
+```python
|
|
|
+class DataSourceAdapter(Protocol):
|
|
|
+ database_type: str
|
|
|
+
|
|
|
+ def build_url(
|
|
|
+ self,
|
|
|
+ definition: DataSourceDefinition,
|
|
|
+ credential: DataSourceCredential,
|
|
|
+ ) -> URL: ...
|
|
|
+
|
|
|
+ def connect_args(
|
|
|
+ self,
|
|
|
+ definition: DataSourceDefinition,
|
|
|
+ credential: DataSourceCredential,
|
|
|
+ ) -> dict[str, object]: ...
|
|
|
+
|
|
|
+ def validate_options(self, options: Mapping[str, object]) -> None: ...
|
|
|
+
|
|
|
+ def configure_transaction(
|
|
|
+ self,
|
|
|
+ connection: Connection,
|
|
|
+ purpose: str,
|
|
|
+ ) -> None: ...
|
|
|
+
|
|
|
+ def probe(self, connection: Connection) -> None: ...
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Implement the allowlisted adapters**
|
|
|
+
|
|
|
+PostgreSQL returns:
|
|
|
+
|
|
|
+```python
|
|
|
+{
|
|
|
+ "connect_timeout": 5,
|
|
|
+ "keepalives": 1,
|
|
|
+ "keepalives_idle": 30,
|
|
|
+ "keepalives_interval": 10,
|
|
|
+ "keepalives_count": 3,
|
|
|
+ "application_name": "dataops-platform-datasource",
|
|
|
+ "options": "-c statement_timeout=30000",
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+MySQL returns:
|
|
|
+
|
|
|
+```python
|
|
|
+{
|
|
|
+ "connect_timeout": 5,
|
|
|
+ "read_timeout": 30,
|
|
|
+ "write_timeout": 30,
|
|
|
+ "charset": "utf8mb4",
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Both execute `text("SELECT 1")` in `probe()`.
|
|
|
+
|
|
|
+Build the PostgreSQL `statement_timeout` value from the validated
|
|
|
+`query_timeout`; keep it platform-controlled. MySQL uses the same bounded value
|
|
|
+for `read_timeout` and `write_timeout`.
|
|
|
+
|
|
|
+Allow only documented TLS settings: PostgreSQL `sslmode`, `sslrootcert`,
|
|
|
+`sslcert`, and `sslkey`; MySQL `ssl_ca`, `ssl_cert`, and `ssl_key`. File paths
|
|
|
+must resolve under the configured certificate directory. Reject unknown option
|
|
|
+keys, URL fragments, frontend driver names, and raw `connect_args`.
|
|
|
+
|
|
|
+For every purpose except `dataflow_write`, configure the database transaction
|
|
|
+as read-only before yielding the connection. PostgreSQL executes
|
|
|
+`SET TRANSACTION READ ONLY`; MySQL executes the equivalent supported by MySQL
|
|
|
+8. Map a database read-only violation to
|
|
|
+`DATASOURCE_READ_ONLY_VIOLATION`. Never retry or replay write transactions.
|
|
|
+
|
|
|
+- [ ] **Step 5: Implement temporary connection testing**
|
|
|
+
|
|
|
+```python
|
|
|
+engine = create_engine(
|
|
|
+ adapter.build_url(definition, credential),
|
|
|
+ poolclass=NullPool,
|
|
|
+ connect_args=adapter.connect_args(definition, credential),
|
|
|
+)
|
|
|
+try:
|
|
|
+ with engine.connect() as connection:
|
|
|
+ adapter.probe(connection)
|
|
|
+finally:
|
|
|
+ engine.dispose()
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Run adapter tests**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_adapters.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add app/core/data_source/adapters \
|
|
|
+ tests/core/data_source/test_adapters.py requirements.txt
|
|
|
+git commit -m "feat: add PostgreSQL and MySQL datasource adapters"
|
|
|
+```
|
|
|
+
|
|
|
+## Task 7: Build the per-source circuit breaker
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `app/core/data_source/circuit_breaker.py`
|
|
|
+- Test: `tests/core/data_source/test_circuit_breaker.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing state-machine tests**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_breaker_opens_after_three_failures_and_recovers():
|
|
|
+ clock = FakeClock()
|
|
|
+ breaker = CircuitBreaker(
|
|
|
+ failure_threshold=3,
|
|
|
+ recovery_seconds=30,
|
|
|
+ clock=clock,
|
|
|
+ )
|
|
|
+
|
|
|
+ breaker.record_failure()
|
|
|
+ breaker.record_failure()
|
|
|
+ breaker.record_failure()
|
|
|
+ assert breaker.state == "open"
|
|
|
+
|
|
|
+ with pytest.raises(DataSourceCircuitOpen):
|
|
|
+ with breaker.probe_permission():
|
|
|
+ pass
|
|
|
+
|
|
|
+ clock.advance(30)
|
|
|
+ with breaker.probe_permission():
|
|
|
+ breaker.record_success()
|
|
|
+
|
|
|
+ assert breaker.state == "closed"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Verify failure**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_circuit_breaker.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because `CircuitBreaker` is undefined.
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement closed/open/half-open**
|
|
|
+
|
|
|
+Use a `threading.Lock`. Only one caller may own the half-open probe. A normal
|
|
|
+query failure does not open the breaker; only connection creation, checkout
|
|
|
+disconnect and probe failures count.
|
|
|
+
|
|
|
+- [ ] **Step 4: Run tests**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_circuit_breaker.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS, including concurrent half-open callers.
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add app/core/data_source/circuit_breaker.py \
|
|
|
+ tests/core/data_source/test_circuit_breaker.py
|
|
|
+git commit -m "feat: isolate datasource failures with circuit breakers"
|
|
|
+```
|
|
|
+
|
|
|
+## Task 8: Build the worker-local pool registry
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `app/core/data_source/pool_registry.py`
|
|
|
+- Test: `tests/core/data_source/test_pool_registry.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing reuse, drain and eviction tests**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_registry_reuses_same_engine_and_invalidates_new_version():
|
|
|
+ registry = PoolRegistry(settings)
|
|
|
+ first = PoolKey(DATA_SOURCE_UID, 1, "hash-a")
|
|
|
+ second = PoolKey(DATA_SOURCE_UID, 2, "hash-b")
|
|
|
+
|
|
|
+ with registry.lease(first, lambda: engine_factory(first)) as engine_a:
|
|
|
+ with registry.lease(first, lambda: engine_factory(first)) as engine_b:
|
|
|
+ assert engine_a is engine_b
|
|
|
+
|
|
|
+ with registry.lease(second, lambda: engine_factory(second)) as engine_c:
|
|
|
+ assert engine_c is not engine_a
|
|
|
+
|
|
|
+ assert engine_a.disposed is True
|
|
|
+
|
|
|
+
|
|
|
+def test_idle_pool_is_evicted_only_after_last_lease_returns():
|
|
|
+ with registry.lease(key, lambda: engine):
|
|
|
+ clock.advance(901)
|
|
|
+ registry.evict_idle()
|
|
|
+ assert engine.disposed is False
|
|
|
+
|
|
|
+ registry.evict_idle()
|
|
|
+ assert engine.disposed is True
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Verify failure**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_pool_registry.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because the registry does not exist.
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement pool entries**
|
|
|
+
|
|
|
+```python
|
|
|
+@dataclass
|
|
|
+class PoolEntry:
|
|
|
+ key: PoolKey
|
|
|
+ engine: Engine
|
|
|
+ breaker: CircuitBreaker
|
|
|
+ created_at: float
|
|
|
+ last_used_at: float
|
|
|
+ leases: int = 0
|
|
|
+ draining: bool = False
|
|
|
+ checkout_wait_ms: float = 0.0
|
|
|
+```
|
|
|
+
|
|
|
+Protect the entry map with `threading.RLock`. Do not hold the registry lock
|
|
|
+while executing SQL. Create one Engine per key using:
|
|
|
+
|
|
|
+```python
|
|
|
+create_engine(
|
|
|
+ url,
|
|
|
+ pool_size=settings.pool_size,
|
|
|
+ max_overflow=settings.max_overflow,
|
|
|
+ pool_timeout=settings.pool_timeout,
|
|
|
+ pool_recycle=settings.pool_recycle,
|
|
|
+ pool_pre_ping=True,
|
|
|
+ pool_use_lifo=True,
|
|
|
+ pool_reset_on_return="rollback",
|
|
|
+ connect_args=connect_args,
|
|
|
+)
|
|
|
+```
|
|
|
+
|
|
|
+Resolve `settings` from the server defaults plus the validated per-source
|
|
|
+`pool_size` and `max_overflow` override. A lease for a new key must mark all
|
|
|
+older keys for the same UID as draining.
|
|
|
+
|
|
|
+- [ ] **Step 4: Add invalidation and bounded idle cache**
|
|
|
+
|
|
|
+Implement:
|
|
|
+
|
|
|
+```python
|
|
|
+invalidate(data_source_uid: str, reason: str) -> None
|
|
|
+expire_draining(now: float | None = None) -> int
|
|
|
+evict_idle(now: float | None = None) -> int
|
|
|
+close_all() -> None
|
|
|
+snapshot() -> list[PoolStatus]
|
|
|
+```
|
|
|
+
|
|
|
+When the idle-entry count exceeds `max_idle_pools`, dispose least-recently-used
|
|
|
+idle entries first. `invalidate()` immediately blocks new leases for the old
|
|
|
+key, lets existing leases drain for a bounded grace period, then
|
|
|
+`expire_draining()` force-disposes the Engine. Returning a lease after forced
|
|
|
+disposal must not re-register the old Engine.
|
|
|
+
|
|
|
+- [ ] **Step 5: Add SQLAlchemy event metrics**
|
|
|
+
|
|
|
+Track `connect`, `checkout`, `checkin` and `invalidate`. Store counts only;
|
|
|
+never inspect or log DBAPI connection arguments.
|
|
|
+
|
|
|
+- [ ] **Step 6: Run pool tests**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_pool_registry.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS, including a 50-thread lease/release test with no duplicate
|
|
|
+Engine for the same key and no leaked leases.
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add app/core/data_source/pool_registry.py \
|
|
|
+ tests/core/data_source/test_pool_registry.py
|
|
|
+git commit -m "feat: add lazy worker-local datasource pool registry"
|
|
|
+```
|
|
|
+
|
|
|
+## Task 9: Implement the public connection manager
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `app/core/data_source/manager.py`
|
|
|
+- Create: `app/core/data_source/runtime.py`
|
|
|
+- Test: `tests/core/data_source/test_manager.py`
|
|
|
+- Modify: `gunicorn_config.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing manager tests**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_manager_resolves_definition_credential_and_adapter():
|
|
|
+ with manager.connect(DATA_SOURCE_UID, purpose="metadata_preview") as connection:
|
|
|
+ connection.execute(text("SELECT 1"))
|
|
|
+
|
|
|
+ assert definitions.requested_uid == DATA_SOURCE_UID
|
|
|
+ assert credentials.requested_version == 4
|
|
|
+ assert adapters.requested_type == "postgresql"
|
|
|
+ assert registry.last_key.credential_version == 4
|
|
|
+
|
|
|
+
|
|
|
+def test_write_requires_explicit_write_purpose(manager, fake_engine):
|
|
|
+ with manager.connect(DATA_SOURCE_UID, purpose="metadata_preview"):
|
|
|
+ pass
|
|
|
+ read_transaction = fake_engine.transactions[-1]
|
|
|
+ assert read_transaction.rolled_back is True
|
|
|
+ assert read_transaction.committed is False
|
|
|
+
|
|
|
+ with manager.connect(DATA_SOURCE_UID, purpose="dataflow_write"):
|
|
|
+ pass
|
|
|
+ write_transaction = fake_engine.transactions[-1]
|
|
|
+ assert write_transaction.committed is True
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Verify failure**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_manager.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because `DataSourceConnectionManager` does not exist.
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement the context manager**
|
|
|
+
|
|
|
+```python
|
|
|
+@contextmanager
|
|
|
+def connect(self, data_source_uid: str, purpose: str):
|
|
|
+ definition = self.definitions.get(data_source_uid)
|
|
|
+ if definition is None:
|
|
|
+ raise DataSourceNotFound(data_source_uid)
|
|
|
+ credential = self.credentials.get_active(
|
|
|
+ data_source_uid,
|
|
|
+ definition.credential_version,
|
|
|
+ )
|
|
|
+ adapter = adapter_for(definition.database_type)
|
|
|
+ key = PoolKey(
|
|
|
+ data_source_uid=data_source_uid,
|
|
|
+ credential_version=definition.credential_version,
|
|
|
+ config_fingerprint=definition.connection_fingerprint(),
|
|
|
+ )
|
|
|
+ with self.registry.lease(
|
|
|
+ key,
|
|
|
+ lambda: adapter.create_pooled_engine(definition, credential, self.settings),
|
|
|
+ ) as engine:
|
|
|
+ with engine.connect() as connection:
|
|
|
+ transaction = connection.begin()
|
|
|
+ try:
|
|
|
+ adapter.configure_transaction(connection, purpose)
|
|
|
+ yield ManagedConnection(connection, purpose)
|
|
|
+ if purpose == "dataflow_write":
|
|
|
+ transaction.commit()
|
|
|
+ else:
|
|
|
+ transaction.rollback()
|
|
|
+ except Exception:
|
|
|
+ transaction.rollback()
|
|
|
+ raise
|
|
|
+```
|
|
|
+
|
|
|
+`ManagedConnection.execute()` records query duration and translates known
|
|
|
+driver timeout and read-only exceptions to the stable safe errors. Measure
|
|
|
+checkout wait around `engine.connect()`. It must not retry statements or
|
|
|
+transactions.
|
|
|
+
|
|
|
+- [ ] **Step 4: Create one manager per Worker**
|
|
|
+
|
|
|
+`runtime.py` lazily constructs one manager in a process-local variable guarded
|
|
|
+by a lock. Add:
|
|
|
+
|
|
|
+```python
|
|
|
+def close_data_source_runtime() -> None:
|
|
|
+ manager = _manager
|
|
|
+ if manager is not None:
|
|
|
+ manager.close()
|
|
|
+```
|
|
|
+
|
|
|
+Call it from Gunicorn `worker_exit`, not from every Flask request.
|
|
|
+
|
|
|
+- [ ] **Step 5: Run manager tests**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_manager.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add app/core/data_source/manager.py app/core/data_source/runtime.py \
|
|
|
+ tests/core/data_source/test_manager.py gunicorn_config.py
|
|
|
+git commit -m "feat: expose managed datasource connections"
|
|
|
+```
|
|
|
+
|
|
|
+## Task 10: Orchestrate secure data-source lifecycle
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `app/core/data_source/service.py`
|
|
|
+- Modify: `app/api/data_source/routes.py`
|
|
|
+- Modify: `app/core/system/permissions.py`
|
|
|
+- Test: `tests/test_datasource_lifecycle_api.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing API lifecycle tests**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_create_stores_secret_only_in_credential_store(
|
|
|
+ client, editor_token, fake_graph, credential_repository
|
|
|
+):
|
|
|
+ response = client.post(
|
|
|
+ "/api/datasource/save",
|
|
|
+ json=POSTGRES_SOURCE_PAYLOAD,
|
|
|
+ headers=bearer(editor_token),
|
|
|
+ )
|
|
|
+
|
|
|
+ assert response.status_code == 201
|
|
|
+ graph_properties = fake_graph.saved_properties
|
|
|
+ assert "password" not in graph_properties
|
|
|
+ assert "username" not in graph_properties
|
|
|
+ assert graph_properties["credential_version"] == 1
|
|
|
+ assert credential_repository.decrypt_latest().password == "source-pass"
|
|
|
+
|
|
|
+
|
|
|
+def test_update_invalidates_old_pool(client, editor_token, pool_registry):
|
|
|
+ response = client.post(
|
|
|
+ "/api/datasource/save",
|
|
|
+ json={**UPDATED_PAYLOAD, "uid": DATA_SOURCE_UID},
|
|
|
+ headers=bearer(editor_token),
|
|
|
+ )
|
|
|
+
|
|
|
+ assert response.status_code == 200
|
|
|
+ assert pool_registry.invalidations == [
|
|
|
+ (DATA_SOURCE_UID, "configuration_changed")
|
|
|
+ ]
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Verify failure**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/test_datasource_lifecycle_api.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because routes still write request properties directly to Neo4j.
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement save/update orchestration**
|
|
|
+
|
|
|
+`DataSourceService.save()` must:
|
|
|
+
|
|
|
+1. Validate only `postgresql` and `mysql`.
|
|
|
+2. Test using Adapter + `NullPool`.
|
|
|
+3. Write an encrypted immutable credential version in a PostgreSQL transaction.
|
|
|
+4. Enqueue `datasource.credential_version_created` with safe definition fields.
|
|
|
+5. Save the secret-free Neo4j definition.
|
|
|
+6. Invalidate the old pool after the definition points to the new version.
|
|
|
+7. Return the sanitized definition.
|
|
|
+
|
|
|
+On Neo4j failure, retain the prior definition and record the newly created
|
|
|
+credential as an orphan candidate for reconciliation.
|
|
|
+
|
|
|
+For an existing source, omitted username/password means “reuse the active
|
|
|
+credential internally”: decrypt it only inside the service, combine any
|
|
|
+explicit replacement fields, and write the next immutable credential version.
|
|
|
+Never send the old credential back to the frontend. Per-source pool overrides
|
|
|
+and TLS fields pass through the server allowlists before connection testing.
|
|
|
+
|
|
|
+- [ ] **Step 4: Implement delete orchestration**
|
|
|
+
|
|
|
+Order:
|
|
|
+
|
|
|
+```python
|
|
|
+registry.invalidate(uid, reason="datasource_deleted")
|
|
|
+definitions.delete(uid)
|
|
|
+credentials.revoke_all(uid, actor_id)
|
|
|
+enqueue_outbox(..., event_type="datasource.deleted", payload={"uid": uid})
|
|
|
+```
|
|
|
+
|
|
|
+If PostgreSQL finalization fails, the reconciliation command must detect a
|
|
|
+deleted graph definition with active credentials.
|
|
|
+
|
|
|
+- [ ] **Step 5: Refactor HTTP routes**
|
|
|
+
|
|
|
+Routes call `DataSourceService` only. Remove imports of `create_engine` and
|
|
|
+`URL` from `app/api/data_source/routes.py`. Use real HTTP status codes.
|
|
|
+
|
|
|
+Keep the existing governance permissions for normal data-source operations:
|
|
|
+
|
|
|
+- viewer: `READ_GOVERNANCE` for list, detail and approved read use;
|
|
|
+- editor: viewer permissions plus `EDIT_GOVERNANCE` for save, update, delete,
|
|
|
+ connection test and dataflow use;
|
|
|
+- admin: editor permissions plus the new
|
|
|
+ `DATASOURCE_POOL_MANAGE = "datasources:pools:manage"` for pool diagnostics
|
|
|
+ and forced invalidation.
|
|
|
+
|
|
|
+Do not grant `DATASOURCE_POOL_MANAGE` to editor or viewer.
|
|
|
+
|
|
|
+- [ ] **Step 6: Run API tests**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/test_datasource_lifecycle_api.py \
|
|
|
+ tests/test_permission_matrix.py \
|
|
|
+ tests/test_datasource_api_security.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 7: Regenerate OpenAPI**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python scripts/generate_openapi.py
|
|
|
+.venv/bin/python -m pytest tests/test_architecture_artifacts.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: generated contract matches route source.
|
|
|
+
|
|
|
+- [ ] **Step 8: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add app/core/data_source/service.py app/api/data_source/routes.py \
|
|
|
+ app/core/system/permissions.py tests/test_datasource_lifecycle_api.py \
|
|
|
+ tests/test_permission_matrix.py tests/test_datasource_api_security.py \
|
|
|
+ docs/architecture/OPENAPI.yaml
|
|
|
+git commit -m "feat: secure datasource lifecycle APIs"
|
|
|
+```
|
|
|
+
|
|
|
+## Task 11: Make the data-source UI secret-free
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `frontend/src/views/dataGovernance/dataSource/index.vue`
|
|
|
+- Modify: `frontend/src/views/dataGovernance/dataSource/components/edit.vue`
|
|
|
+- Modify: `frontend/src/api/dataOrigin.js`
|
|
|
+- Create: `tests/test_datasource_frontend_contract.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write the failing frontend contract test**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_datasource_ui_does_not_render_or_replay_stored_credentials():
|
|
|
+ listing = Path(
|
|
|
+ "frontend/src/views/dataGovernance/dataSource/index.vue"
|
|
|
+ ).read_text(encoding="utf-8")
|
|
|
+ editor = Path(
|
|
|
+ "frontend/src/views/dataGovernance/dataSource/components/edit.vue"
|
|
|
+ ).read_text(encoding="utf-8")
|
|
|
+
|
|
|
+ assert "value: 'username'" not in listing
|
|
|
+ assert "value: 'param'" not in listing
|
|
|
+ assert "credential_configured" in listing
|
|
|
+ assert "Object.assign(this.formValues, this.itemData)" not in editor
|
|
|
+ assert "datasourceParse" not in editor
|
|
|
+ assert "key: 'param'" not in editor
|
|
|
+ assert "postgresql" in editor
|
|
|
+ assert "mysql" in editor
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Verify failure**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/test_datasource_frontend_contract.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because the existing UI lists username and arbitrary connection
|
|
|
+parameters and copies the full list item into the credential form.
|
|
|
+
|
|
|
+- [ ] **Step 3: Refactor list and identity handling**
|
|
|
+
|
|
|
+Use `uid`, never the Neo4j numeric `id`, for edit and delete. Remove username
|
|
|
+and free-form connection-parameter columns. Show a
|
|
|
+`credential_configured` status chip instead. Keep host, port, database and
|
|
|
+schema visible because they are non-secret definition fields.
|
|
|
+
|
|
|
+- [ ] **Step 4: Refactor create/edit/test payloads**
|
|
|
+
|
|
|
+Use a fixed database-type selector containing only PostgreSQL and MySQL.
|
|
|
+Remove quick connection-string parsing and the free-form `param` field. On
|
|
|
+edit, copy only safe definition fields into `formValues`; initialize username
|
|
|
+and password as empty and show “凭据已配置,留空则沿用当前凭据”. Omit blank
|
|
|
+credential fields from the request.
|
|
|
+
|
|
|
+For a new source, require username and password. For an existing source,
|
|
|
+send its UID so the backend may combine safe field edits with the current
|
|
|
+credential internally and create the next immutable credential version.
|
|
|
+Connection testing follows the same rule: new source uses entered credentials;
|
|
|
+existing source may use UID plus any explicitly entered replacement
|
|
|
+credentials.
|
|
|
+
|
|
|
+- [ ] **Step 5: Remove obsolete API helpers and run verification**
|
|
|
+
|
|
|
+Remove `datasourceParse()` when no other import remains. Keep the backend
|
|
|
+compatibility endpoint out of navigation until its later removal is separately
|
|
|
+verified.
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/test_datasource_frontend_contract.py \
|
|
|
+ tests/test_datasource_api_security.py -q
|
|
|
+cd frontend
|
|
|
+npm run build
|
|
|
+```
|
|
|
+
|
|
|
+Expected: tests PASS and the production frontend build exits 0.
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add frontend/src/views/dataGovernance/dataSource/index.vue \
|
|
|
+ frontend/src/views/dataGovernance/dataSource/components/edit.vue \
|
|
|
+ frontend/src/api/dataOrigin.js \
|
|
|
+ tests/test_datasource_frontend_contract.py
|
|
|
+git commit -m "fix: make datasource management UI secret-free"
|
|
|
+```
|
|
|
+
|
|
|
+## Task 12: Add diagnostics and health aggregation
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `app/api/data_source/routes.py`
|
|
|
+- Modify: `app/core/system/health.py`
|
|
|
+- Test: `tests/test_datasource_pool_diagnostics.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing diagnostics tests**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_admin_pool_status_is_safe(client, admin_token, seeded_pool):
|
|
|
+ response = client.get(
|
|
|
+ f"/api/datasource/{DATA_SOURCE_UID}/pool",
|
|
|
+ headers=bearer(admin_token),
|
|
|
+ )
|
|
|
+ body = response.get_json()["data"]
|
|
|
+
|
|
|
+ assert body["data_source_uid"] == DATA_SOURCE_UID
|
|
|
+ assert body["pool_state"] == "healthy"
|
|
|
+ assert "host" not in body
|
|
|
+ assert "username" not in body
|
|
|
+ assert "password" not in response.get_data(as_text=True).lower()
|
|
|
+
|
|
|
+
|
|
|
+def test_platform_health_contains_only_pool_aggregates(client):
|
|
|
+ body = client.get("/api/system/health").get_json()["data"]["components"]
|
|
|
+ assert body["datasource_pools"] == {
|
|
|
+ "active_pool_count": 1,
|
|
|
+ "degraded_pool_count": 0,
|
|
|
+ "open_circuit_count": 0,
|
|
|
+ "checked_out_total": 0,
|
|
|
+ "pool_timeout_total": 0,
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Verify failure**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/test_datasource_pool_diagnostics.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because diagnostics are not registered.
|
|
|
+
|
|
|
+- [ ] **Step 3: Add admin-only endpoints**
|
|
|
+
|
|
|
+```text
|
|
|
+GET /api/datasource/pools
|
|
|
+GET /api/datasource/{data_source_uid}/pool
|
|
|
+POST /api/datasource/{data_source_uid}/pool/invalidate
|
|
|
+```
|
|
|
+
|
|
|
+All require `datasources:pools:manage`; invalidation accepts only a fixed
|
|
|
+reason enum and records an audit event.
|
|
|
+
|
|
|
+- [ ] **Step 4: Add aggregate health**
|
|
|
+
|
|
|
+If the runtime has not created a registry, report zeros without creating any
|
|
|
+data-source connections. Do not perform a network probe from platform health.
|
|
|
+
|
|
|
+- [ ] **Step 5: Run tests**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/test_datasource_pool_diagnostics.py \
|
|
|
+ tests/test_outbox.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add app/api/data_source/routes.py app/core/system/health.py \
|
|
|
+ tests/test_datasource_pool_diagnostics.py
|
|
|
+git commit -m "feat: expose safe datasource pool diagnostics"
|
|
|
+```
|
|
|
+
|
|
|
+## Task 13: Add legacy credential report, migration and reconciliation
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `app/commands/report_datasource_credentials.py`
|
|
|
+- Create: `app/commands/migrate_datasource_credentials.py`
|
|
|
+- Create: `app/commands/reconcile_datasource_credentials.py`
|
|
|
+- Test: `tests/core/data_source/test_credential_migration.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing migration tests**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_report_never_returns_plaintext(fake_graph):
|
|
|
+ report = inspect_legacy_datasources(fake_graph)
|
|
|
+
|
|
|
+ assert report == [
|
|
|
+ {
|
|
|
+ "data_source_uid": DATA_SOURCE_UID,
|
|
|
+ "issues": ["plaintext_credentials"],
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ assert "legacy-password" not in json.dumps(report)
|
|
|
+
|
|
|
+
|
|
|
+def test_migration_removes_plaintext_only_after_round_trip_verification(
|
|
|
+ fake_graph, credential_repository
|
|
|
+):
|
|
|
+ result = migrate_one(DATA_SOURCE_UID, fake_graph, credential_repository)
|
|
|
+
|
|
|
+ assert result.status == "migrated"
|
|
|
+ assert credential_repository.decrypt_latest().password == "legacy-password"
|
|
|
+ assert "password" not in fake_graph.node
|
|
|
+ assert "username" not in fake_graph.node
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Verify failure**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_credential_migration.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because commands do not exist.
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement read-only reporting**
|
|
|
+
|
|
|
+Report issue categories only:
|
|
|
+
|
|
|
+```text
|
|
|
+missing_uid
|
|
|
+plaintext_credentials
|
|
|
+missing_credential_reference
|
|
|
+unsupported_database_type
|
|
|
+incomplete_connection_definition
|
|
|
+```
|
|
|
+
|
|
|
+Default output is JSON Lines and excludes host, username and password.
|
|
|
+
|
|
|
+- [ ] **Step 4: Implement explicit migration**
|
|
|
+
|
|
|
+CLI contract:
|
|
|
+
|
|
|
+```bash
|
|
|
+python -m app.commands.migrate_datasource_credentials \
|
|
|
+ --uid <uuid> \
|
|
|
+ --confirm-encrypt-and-remove-plaintext
|
|
|
+```
|
|
|
+
|
|
|
+Without the confirmation flag, exit non-zero without mutation. For each source:
|
|
|
+
|
|
|
+1. Backfill UUIDv7 when missing.
|
|
|
+2. Encrypt username/password.
|
|
|
+3. Decrypt and compare in memory.
|
|
|
+4. Update credential reference.
|
|
|
+5. Remove plaintext Neo4j fields.
|
|
|
+6. Record safe audit event.
|
|
|
+
|
|
|
+- [ ] **Step 5: Implement reconciliation**
|
|
|
+
|
|
|
+Default is report-only. Compare Neo4j references to PostgreSQL credentials and
|
|
|
+report:
|
|
|
+
|
|
|
+```text
|
|
|
+orphaned_credential
|
|
|
+missing_credential
|
|
|
+stale_credential_version
|
|
|
+revoked_credential_referenced
|
|
|
+plaintext_remaining
|
|
|
+```
|
|
|
+
|
|
|
+Repairs require `--repair --uid <uuid>`. Never delete source data or credentials
|
|
|
+in a broad repair run.
|
|
|
+
|
|
|
+- [ ] **Step 6: Run tests**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/core/data_source/test_credential_migration.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add app/commands/report_datasource_credentials.py \
|
|
|
+ app/commands/migrate_datasource_credentials.py \
|
|
|
+ app/commands/reconcile_datasource_credentials.py \
|
|
|
+ tests/core/data_source/test_credential_migration.py
|
|
|
+git commit -m "feat: migrate legacy datasource credentials safely"
|
|
|
+```
|
|
|
+
|
|
|
+## Task 14: Add isolated PostgreSQL and MySQL acceptance services
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `deploy/docker/docker-compose.yml`
|
|
|
+- Create: `deploy/docker/datasources/postgres/init.sql`
|
|
|
+- Create: `deploy/docker/datasources/mysql/init.sql`
|
|
|
+- Modify: `deploy/docker/.env.example`
|
|
|
+- Modify: `tests/test_local_docker_contract.py`
|
|
|
+- Create: `tests/integration/test_datasource_pools.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing Compose contract tests**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_compose_has_isolated_external_datasources():
|
|
|
+ source = COMPOSE.read_text(encoding="utf-8")
|
|
|
+
|
|
|
+ assert "source-postgres:" in source
|
|
|
+ assert "source-mysql:" in source
|
|
|
+ assert "dataops-test-source-postgres:" in source
|
|
|
+ assert "dataops-test-source-mysql:" in source
|
|
|
+ assert "DATASOURCE_CREDENTIAL_MASTER_KEY:" in source
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Verify failure**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/test_local_docker_contract.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because the services are absent.
|
|
|
+
|
|
|
+- [ ] **Step 3: Add isolated services**
|
|
|
+
|
|
|
+Use pinned major/minor image tags:
|
|
|
+
|
|
|
+```yaml
|
|
|
+source-postgres:
|
|
|
+ image: postgres:16-alpine
|
|
|
+ environment:
|
|
|
+ POSTGRES_DB: source_db
|
|
|
+ POSTGRES_USER: source_reader
|
|
|
+ POSTGRES_PASSWORD: source-postgres-test-password
|
|
|
+ ports: ["25432:5432"]
|
|
|
+ volumes:
|
|
|
+ - dataops-test-source-postgres:/var/lib/postgresql/data
|
|
|
+ - ./datasources/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
|
|
+
|
|
|
+source-mysql:
|
|
|
+ image: mysql:8.4
|
|
|
+ environment:
|
|
|
+ MYSQL_DATABASE: source_db
|
|
|
+ MYSQL_USER: source_reader
|
|
|
+ MYSQL_PASSWORD: source-mysql-test-password
|
|
|
+ MYSQL_ROOT_PASSWORD: source-mysql-root-test-password
|
|
|
+ ports: ["23306:3306"]
|
|
|
+ volumes:
|
|
|
+ - dataops-test-source-mysql:/var/lib/mysql
|
|
|
+ - ./datasources/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
|
|
+```
|
|
|
+
|
|
|
+Both join `dataops-test-net` and define real health checks. Add test-only
|
|
|
+32-byte base64 master key to the backend environment.
|
|
|
+
|
|
|
+- [ ] **Step 4: Seed equivalent acceptance tables**
|
|
|
+
|
|
|
+Both databases create:
|
|
|
+
|
|
|
+```sql
|
|
|
+CREATE TABLE acceptance_customers (
|
|
|
+ customer_id INTEGER PRIMARY KEY,
|
|
|
+ customer_name VARCHAR(100) NOT NULL
|
|
|
+);
|
|
|
+INSERT INTO acceptance_customers VALUES
|
|
|
+ (1, 'Alpha'),
|
|
|
+ (2, 'Beta');
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Add integration tests**
|
|
|
+
|
|
|
+Test:
|
|
|
+
|
|
|
+- create both definitions through the API;
|
|
|
+- run `SELECT COUNT(*)` through `DataSourceConnectionManager`;
|
|
|
+- run 100 concurrent read operations;
|
|
|
+- assert every result is `2`;
|
|
|
+- assert no entry exceeds `pool_size + max_overflow`;
|
|
|
+- assert all leases return to zero;
|
|
|
+- advance the clock past 15 minutes and prove idle pools are disposed;
|
|
|
+- close and recreate the Worker runtime and prove pools are rebuilt lazily;
|
|
|
+- delete a source and prove no new connection can be acquired.
|
|
|
+
|
|
|
+- [ ] **Step 6: Start services and run integration**
|
|
|
+
|
|
|
+```bash
|
|
|
+docker compose -f deploy/docker/docker-compose.yml up -d \
|
|
|
+ source-postgres source-mysql backend
|
|
|
+
|
|
|
+TEST_DATABASE_URL=postgresql://dataops:dataops-test-password@localhost:15432/dataops \
|
|
|
+ TEST_SOURCE_POSTGRES_URL=postgresql://source_reader:source-postgres-test-password@localhost:25432/source_db \
|
|
|
+ TEST_SOURCE_MYSQL_URL=mysql+pymysql://source_reader:source-mysql-test-password@localhost:23306/source_db \
|
|
|
+ .venv/bin/python -m pytest \
|
|
|
+ tests/integration/test_datasource_pools.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS; Compose reports both sources healthy.
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add deploy/docker/docker-compose.yml deploy/docker/datasources \
|
|
|
+ deploy/docker/.env.example tests/test_local_docker_contract.py \
|
|
|
+ tests/integration/test_datasource_pools.py
|
|
|
+git commit -m "test: add isolated datasource pool stack"
|
|
|
+```
|
|
|
+
|
|
|
+## Task 15: Verify outage, recovery and credential invalidation
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `tests/integration/test_datasource_pool_failures.py`
|
|
|
+- Modify: `deploy/docker/README.md`
|
|
|
+
|
|
|
+- [ ] **Step 1: Add outage acceptance test**
|
|
|
+
|
|
|
+The test helper must execute these externally controlled phases:
|
|
|
+
|
|
|
+```python
|
|
|
+assert query(POSTGRES_UID).scalar_one() == 2
|
|
|
+assert query(MYSQL_UID).scalar_one() == 2
|
|
|
+
|
|
|
+ compose_stop("source-mysql")
|
|
|
+ wait_for_error(MYSQL_UID, "DATASOURCE_CONNECTION_FAILED", attempts=3)
|
|
|
+ assert query_error(MYSQL_UID).code == "DATASOURCE_CIRCUIT_OPEN"
|
|
|
+ assert query(POSTGRES_UID).scalar_one() == 2
|
|
|
+ assert platform_health()["database"] is True
|
|
|
+
|
|
|
+ compose_start_and_wait_healthy("source-mysql")
|
|
|
+ clock.advance(30)
|
|
|
+ assert query(MYSQL_UID).scalar_one() == 2
|
|
|
+ assert pool_status(MYSQL_UID)["pool_state"] == "healthy"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Make the outage test explicitly opt-in and self-restoring**
|
|
|
+
|
|
|
+The test may run Docker Compose commands only when
|
|
|
+`RUN_DATASOURCE_OUTAGE_TEST=1`. Inject a fake monotonic clock into the breaker
|
|
|
+so recovery does not require a 30-second sleep. Wrap the outage in
|
|
|
+`try/finally`; the `finally` block always starts `source-mysql` and waits for
|
|
|
+its Compose health condition. A failed assertion must never leave the service
|
|
|
+stopped.
|
|
|
+
|
|
|
+- [ ] **Step 3: Verify the default skip**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python -m pytest \
|
|
|
+ tests/integration/test_datasource_pool_failures.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: SKIP unless `RUN_DATASOURCE_OUTAGE_TEST=1`.
|
|
|
+
|
|
|
+- [ ] **Step 4: Run outage, recovery and credential-rotation acceptance**
|
|
|
+
|
|
|
+```bash
|
|
|
+RUN_DATASOURCE_OUTAGE_TEST=1 \
|
|
|
+ .venv/bin/python -m pytest \
|
|
|
+ tests/integration/test_datasource_pool_failures.py -q
|
|
|
+```
|
|
|
+
|
|
|
+Within the same test process: establish both pools, stop MySQL, open only the
|
|
|
+MySQL circuit after three failures, prove PostgreSQL and the platform control
|
|
|
+database remain healthy, restart MySQL, advance the injected clock, and prove
|
|
|
+the half-open probe closes the circuit. Then update a test credential version,
|
|
|
+assert the old Engine is disposed, and prove the next query uses the new
|
|
|
+version. Poll the Compose health condition rather than sleeping a fixed
|
|
|
+duration.
|
|
|
+
|
|
|
+- [ ] **Step 5: Document operations**
|
|
|
+
|
|
|
+Add:
|
|
|
+
|
|
|
+- pool defaults and environment variables;
|
|
|
+- report/migrate/reconcile commands;
|
|
|
+- admin diagnostics;
|
|
|
+- safe invalidation;
|
|
|
+- outage recovery;
|
|
|
+- explicit warning that platform PostgreSQL is not managed by this registry.
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add tests/integration/test_datasource_pool_failures.py \
|
|
|
+ deploy/docker/README.md
|
|
|
+git commit -m "test: verify datasource outage and recovery"
|
|
|
+```
|
|
|
+
|
|
|
+## Task 16: Final contract, release sync and complete verification
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `docs/architecture/ARCHITECTURE_OVERVIEW.md`
|
|
|
+- Modify: `docs/architecture/DATA_MODEL.md`
|
|
|
+- Modify: `docs/architecture/OPENAPI.yaml`
|
|
|
+- Modify: `deployment/sync_release.sh` only if new source directories are not already copied
|
|
|
+- Test: existing full test suites
|
|
|
+
|
|
|
+- [ ] **Step 1: Update architecture artifacts**
|
|
|
+
|
|
|
+Document:
|
|
|
+
|
|
|
+- control-plane PostgreSQL versus external data-source pools;
|
|
|
+- credential table and Neo4j reference;
|
|
|
+- Worker-local pool multiplication;
|
|
|
+- supported adapters;
|
|
|
+- failure and security boundaries.
|
|
|
+
|
|
|
+- [ ] **Step 2: Regenerate OpenAPI and sync release source**
|
|
|
+
|
|
|
+```bash
|
|
|
+.venv/bin/python scripts/generate_openapi.py
|
|
|
+bash deployment/sync_release.sh
|
|
|
+diff -qr app deployment/app
|
|
|
+diff -qr migrations deployment/migrations
|
|
|
+cmp requirements.txt deployment/requirements.txt
|
|
|
+```
|
|
|
+
|
|
|
+Expected: all diff/cmp commands exit 0.
|
|
|
+
|
|
|
+- [ ] **Step 3: Run full Python verification**
|
|
|
+
|
|
|
+```bash
|
|
|
+TEST_POSTGRES_ADMIN_URL=postgresql://dataops:dataops-test-password@localhost:15432/postgres \
|
|
|
+TEST_DATABASE_URL=postgresql://dataops:dataops-test-password@localhost:15432/dataops \
|
|
|
+TEST_ADMIN_PASSWORD=AdminPass123 \
|
|
|
+ .venv/bin/python -m pytest -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: zero failures and zero errors.
|
|
|
+
|
|
|
+- [ ] **Step 4: Run frontend build**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd frontend
|
|
|
+npm run build
|
|
|
+```
|
|
|
+
|
|
|
+Expected: build exits 0; pre-existing Vue 2 dependency warnings may remain,
|
|
|
+but no new ESLint errors are allowed.
|
|
|
+
|
|
|
+- [ ] **Step 5: Rebuild and smoke-test the complete stack**
|
|
|
+
|
|
|
+```bash
|
|
|
+docker compose -f deploy/docker/docker-compose.yml up -d --build
|
|
|
+docker compose -f deploy/docker/docker-compose.yml ps
|
|
|
+curl -fsS http://localhost:18183/ >/dev/null
|
|
|
+curl -fsS http://localhost:15500/api/system/health
|
|
|
+```
|
|
|
+
|
|
|
+Expected: platform services, `source-postgres` and `source-mysql` are healthy.
|
|
|
+
|
|
|
+- [ ] **Step 6: Scan for plaintext secrets**
|
|
|
+
|
|
|
+```bash
|
|
|
+rg -n \
|
|
|
+ "password.*logger|logger.*password|postgresql://[^[:space:]]+:[^[:space:]@]+@" \
|
|
|
+ app deployment/app docs/architecture
|
|
|
+```
|
|
|
+
|
|
|
+Expected: no data-source credential disclosure. Test-only Compose credentials
|
|
|
+must remain limited to `deploy/docker`.
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit final artifacts**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add -- \
|
|
|
+ docs/architecture/ARCHITECTURE_OVERVIEW.md \
|
|
|
+ docs/architecture/DATA_MODEL.md \
|
|
|
+ docs/architecture/OPENAPI.yaml \
|
|
|
+ deployment/app/api/data_source/routes.py \
|
|
|
+ deployment/app/commands/migrate_datasource_credentials.py \
|
|
|
+ deployment/app/commands/reconcile_datasource_credentials.py \
|
|
|
+ deployment/app/commands/reconcile_governance_uids.py \
|
|
|
+ deployment/app/commands/report_datasource_credentials.py \
|
|
|
+ deployment/app/config/config.py \
|
|
|
+ deployment/app/core/data_source \
|
|
|
+ deployment/app/core/system/health.py \
|
|
|
+ deployment/app/core/system/permissions.py \
|
|
|
+ deployment/gunicorn_config.py \
|
|
|
+ deployment/migrations/versions/20260718_50_datasource_credentials.py \
|
|
|
+ deployment/requirements.txt
|
|
|
+git commit -m "docs: finalize datasource pool operations"
|
|
|
+```
|
|
|
+
|
|
|
+Before staging, inspect `git diff --name-only -- deployment/` and confirm every
|
|
|
+changed release-copy file is the generated counterpart of a source file from
|
|
|
+Tasks 1–15. Do not stage unrelated pre-existing workspace changes.
|
|
|
+
|
|
|
+- [ ] **Step 8: Request code review**
|
|
|
+
|
|
|
+Use the `requesting-code-review` skill. Review specifically for:
|
|
|
+
|
|
|
+- secret leakage;
|
|
|
+- cross-store partial failure;
|
|
|
+- connection leaks;
|
|
|
+- pool multiplication across Workers;
|
|
|
+- unsafe driver or connect-argument injection;
|
|
|
+- write-transaction replay.
|