Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions backend/src/analytics_agent/quickstart.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,31 @@ def _check_prereqs(port: int) -> None:
click.echo(f" ✓ Port {port} available")


def _is_stale_demo_db_failure(db_url: str, stderr: str) -> bool:
"""True when bootstrap failed because the demo-written MySQL DATABASE_URL is unreachable."""
# Matches the URL written by _write_demo_config() on both Mac (host.docker.internal)
# and Linux (172.17.0.1). The literal datahub:datahub@…/talkster combo is
# demo-specific, so a match means we can safely strip the var.
if "mysql+aiomysql://datahub:datahub@" not in db_url or "/talkster" not in db_url:
return False
return "Can't connect" in stderr or "nodename nor servname" in stderr


def _strip_env_vars(env_path: Path, keys: set[str]) -> None:
"""Remove KEY=VALUE lines for the given keys from a .env file."""
if not env_path.exists():
return
kept: list[str] = []
for line in env_path.read_text().splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("#") and "=" in stripped:
k, _, _ = stripped.partition("=")
if k.strip() in keys:
continue
kept.append(line)
env_path.write_text("\n".join(kept) + ("\n" if kept else ""))


def _bootstrap_and_launch(config_dir: Path, port: int, *, open_setup: bool = False) -> None:
"""Run bootstrap (migrations + seeds) then start the server."""
import subprocess as _sp
Expand All @@ -331,6 +356,22 @@ def _bootstrap_and_launch(config_dir: Path, port: int, *, open_setup: bool = Fal
capture_output=True,
text=True,
)
if result.returncode != 0 and _is_stale_demo_db_failure(
env.get("DATABASE_URL", ""), result.stderr
):
click.echo(
" ! Stale demo DATABASE_URL detected (MySQL unreachable). "
"Clearing it and falling back to the default SQLite database…",
err=True,
)
_strip_env_vars(env_path, {"DATABASE_URL"})
env.pop("DATABASE_URL", None)
result = _sp.run(
[sys.executable, "-m", "analytics_agent.cli", "bootstrap"],
env=env,
capture_output=True,
text=True,
)
if result.returncode != 0:
click.echo(result.stderr, err=True)
sys.exit(result.returncode)
Expand Down
86 changes: 86 additions & 0 deletions tests/unit/test_quickstart_stale_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""
Tests for self-healing recovery when `quickstart` runs against a stale demo
DATABASE_URL left in ~/.datahub/analytics-agent/.env by a previous `demo` run.
"""

from __future__ import annotations

from pathlib import Path

import pytest
from analytics_agent.quickstart import _is_stale_demo_db_failure, _strip_env_vars

# ── _is_stale_demo_db_failure ────────────────────────────────────────────────

_DEMO_URL_MAC = "mysql+aiomysql://datahub:datahub@host.docker.internal:3306/talkster"
_DEMO_URL_LINUX = "mysql+aiomysql://datahub:datahub@172.17.0.1:3306/talkster"
_CONNECT_ERR_MAC = "OperationalError: (pymysql.err.OperationalError) (2003, \"Can't connect to MySQL server on 'host.docker.internal'\")"
_CONNECT_ERR_DNS = "socket.gaierror: [Errno 8] nodename nor servname provided, or not known"


@pytest.mark.parametrize("url", [_DEMO_URL_MAC, _DEMO_URL_LINUX])
def test_stale_demo_detected_on_mac_and_linux(url):
assert _is_stale_demo_db_failure(url, _CONNECT_ERR_MAC)
assert _is_stale_demo_db_failure(url, _CONNECT_ERR_DNS)


def test_non_demo_mysql_not_treated_as_stale():
"""A user-supplied MySQL URL must NOT trigger auto-cleanup, even on connect failure."""
user_url = "mysql+aiomysql://prod_user:secret@my-db.example.com:3306/analytics"
assert not _is_stale_demo_db_failure(user_url, _CONNECT_ERR_MAC)


def test_demo_url_but_unrelated_stderr():
"""Random stderr (e.g. migration syntax error) must not falsely trigger recovery."""
assert not _is_stale_demo_db_failure(
_DEMO_URL_MAC, "alembic.util.exc.CommandError: Can't locate revision"
)


def test_empty_db_url():
assert not _is_stale_demo_db_failure("", _CONNECT_ERR_MAC)


def test_sqlite_url_is_not_stale():
assert not _is_stale_demo_db_failure("sqlite+aiosqlite:///x.db", _CONNECT_ERR_MAC)


# ── _strip_env_vars ──────────────────────────────────────────────────────────


def test_strip_env_vars_removes_only_named_keys(tmp_path: Path):
env_file = tmp_path / ".env"
env_file.write_text(
"ANTHROPIC_API_KEY=sk-ant-xxx\n"
"DATABASE_URL=mysql+aiomysql://datahub:datahub@host.docker.internal:3306/talkster\n"
"# A comment line\n"
"DATAHUB_GMS_URL=http://localhost:8080\n"
"\n"
"LLM_PROVIDER=anthropic\n"
)
_strip_env_vars(env_file, {"DATABASE_URL"})
remaining = env_file.read_text()
assert "DATABASE_URL" not in remaining
assert "ANTHROPIC_API_KEY=sk-ant-xxx" in remaining
assert "DATAHUB_GMS_URL=http://localhost:8080" in remaining
assert "# A comment line" in remaining
assert "LLM_PROVIDER=anthropic" in remaining


def test_strip_env_vars_multiple_keys(tmp_path: Path):
env_file = tmp_path / ".env"
env_file.write_text("A=1\nB=2\nC=3\n")
_strip_env_vars(env_file, {"A", "C"})
assert env_file.read_text() == "B=2\n"


def test_strip_env_vars_missing_file_is_noop(tmp_path: Path):
"""Should not raise when the file doesn't exist."""
_strip_env_vars(tmp_path / "nonexistent.env", {"X"})


def test_strip_env_vars_key_not_present(tmp_path: Path):
env_file = tmp_path / ".env"
env_file.write_text("A=1\nB=2\n")
_strip_env_vars(env_file, {"DOES_NOT_EXIST"})
assert env_file.read_text() == "A=1\nB=2\n"
Loading