Skip to content

Commit 767bd16

Browse files
committed
feat: support standalone install with embedded postgres
1 parent c2294a6 commit 767bd16

13 files changed

Lines changed: 458 additions & 107 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ dependencies = [
8787
]
8888

8989
[project.optional-dependencies]
90+
standalone = [
91+
"pixeltable-pgserver>=0.5.1",
92+
]
93+
9094
dev = [
9195
"invoke==2.2.0",
9296
"ruff==0.4.1",

testgen/__main__.py

Lines changed: 136 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
import base64
2+
import importlib
13
import logging
24
import os
5+
import platform
6+
import secrets
37
import signal
48
import subprocess
59
import sys
610
from dataclasses import dataclass, field
711
from datetime import UTC, datetime, timedelta
12+
from importlib.metadata import version as pkg_version
13+
from pathlib import Path
814

915
import click
1016
from click.core import Context
@@ -42,6 +48,13 @@
4248
get_tg_schema,
4349
version_service,
4450
)
51+
from testgen.common.standalone_postgres import (
52+
STANDALONE_URI_ENV_VAR,
53+
get_home_dir as get_testgen_home,
54+
get_server_uri,
55+
is_standalone_mode,
56+
start_server as start_standalone_postgres,
57+
)
4558
from testgen.common.models import with_database_session
4659
from testgen.common.models.profiling_run import ProfilingRun
4760
from testgen.common.models.settings import PersistedSetting
@@ -99,19 +112,23 @@ def invoke(self, ctx: Context):
99112
)
100113
@click.pass_context
101114
def cli(ctx: Context, verbose: bool):
115+
if is_standalone_mode():
116+
start_standalone_postgres()
117+
102118
if verbose:
103119
configure_logging(level=logging.DEBUG)
104120
else:
105121
configure_logging(level=logging.INFO)
106122

107123
ctx.obj = Configuration(verbose=verbose)
108-
status_ok, message = docker_service.check_basic_configuration()
109-
if not status_ok:
110-
click.secho(message, fg="red")
111-
sys.exit(1)
124+
if not is_standalone_mode() and ctx.invoked_subcommand != "standalone-setup":
125+
status_ok, message = docker_service.check_basic_configuration()
126+
if not status_ok:
127+
click.secho(message, fg="red")
128+
sys.exit(1)
112129

113130
if (
114-
ctx.invoked_subcommand not in ["run-app", "ui", "setup-system-db", "upgrade-system-version", "quick-start"]
131+
ctx.invoked_subcommand not in ["run-app", "ui", "setup-system-db", "upgrade-system-version", "quick-start", "standalone-setup"]
115132
and not is_db_revision_up_to_date()
116133
):
117134
click.secho("The system database schema is outdated. Automatically running the following command:", fg="red")
@@ -472,6 +489,110 @@ def quick_start(
472489
click.echo("Quick start has successfully finished.")
473490

474491

492+
@cli.command("standalone-setup", help="Set up TestGen for standalone use with embedded PostgreSQL (no Docker required).")
493+
@click.option("--username", prompt="Admin username", default="admin", help="Username for the TestGen web UI.")
494+
@click.option(
495+
"--password", prompt="Admin password", hide_input=True, confirmation_prompt=True,
496+
default="testgen", help="Password for the TestGen web UI.",
497+
)
498+
def setup_standalone(username: str, password: str):
499+
config_dir = get_testgen_home()
500+
config_path = config_dir / "config.env"
501+
502+
if config_path.exists():
503+
if not click.confirm(f"Config already exists at {config_path}. Overwrite?"):
504+
click.echo("Aborted.")
505+
return
506+
507+
# Generate secrets (same approach as dk-installer)
508+
def generate_secret(length: int = 12) -> str:
509+
alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
510+
return "".join(secrets.choice(alphabet) for _ in range(length))
511+
512+
jwt_key = base64.b64encode(secrets.token_bytes(32)).decode()
513+
decrypt_salt = generate_secret()
514+
decrypt_password = generate_secret()
515+
log_dir = str(config_dir / "log")
516+
517+
config_dir.mkdir(parents=True, exist_ok=True)
518+
519+
config_lines = [
520+
"# TestGen standalone configuration",
521+
"# Generated by: testgen standalone-setup",
522+
"",
523+
"# Standalone mode (embedded PostgreSQL)",
524+
"TG_STANDALONE_MODE=yes",
525+
"",
526+
"# UI credentials",
527+
f"TESTGEN_USERNAME={username}",
528+
f"TESTGEN_PASSWORD={password}",
529+
"",
530+
"# Encryption keys",
531+
f"TG_DECRYPT_SALT={decrypt_salt}",
532+
f"TG_DECRYPT_PASSWORD={decrypt_password}",
533+
f"TG_JWT_HASHING_KEY={jwt_key}",
534+
"",
535+
"# Logging",
536+
f"TESTGEN_LOG_FILE_PATH={log_dir}",
537+
"",
538+
"# Analytics",
539+
"TG_ANALYTICS=yes",
540+
"",
541+
"# Trust target database certificates (for SQL Server, etc.)",
542+
"TG_TARGET_DB_TRUST_SERVER_CERTIFICATE=yes",
543+
"TG_EXPORT_TO_OBSERVABILITY_VERIFY_SSL=no",
544+
]
545+
config_path.write_text("\n".join(config_lines) + "\n")
546+
click.echo(f"Config written to {config_path}")
547+
548+
# Reload settings — the module was already evaluated at import time
549+
# before the config file existed. Reloading re-reads the new file
550+
# and re-evaluates all module-level variables.
551+
importlib.reload(settings)
552+
553+
# Patch Streamlit to support editable-install component resolution
554+
click.echo("Patching Streamlit...")
555+
from testgen.ui.scripts.patch_streamlit import patch as patch_streamlit
556+
patch_streamlit(dev=True)
557+
558+
# Start embedded PostgreSQL (standalone mode is now active via config)
559+
start_standalone_postgres()
560+
561+
# Initialize the database
562+
click.echo("Initializing database...")
563+
run_launch_db_config(delete_db=False)
564+
565+
# Send analytics event for pip install tracking
566+
try:
567+
from testgen.common.mixpanel_service import MixpanelService
568+
569+
mp = MixpanelService()
570+
mp.send_event(
571+
"standalone_setup",
572+
username=username,
573+
install_type="standalone",
574+
version=pkg_version("dataops-testgen"),
575+
python_info=f"{platform.python_implementation()} {platform.python_version()}",
576+
**{"$os": platform.system()},
577+
os_version=platform.release(),
578+
os_arch=platform.machine(),
579+
)
580+
except Exception: # noqa: S110
581+
pass
582+
583+
click.echo("")
584+
click.echo(click.style("TestGen is ready!", fg="green", bold=True))
585+
click.echo("")
586+
click.echo(" To load demo data (optional):")
587+
click.echo(" testgen quick-start")
588+
click.echo("")
589+
click.echo(" Start the application:")
590+
click.echo(" testgen run-app")
591+
click.echo("")
592+
click.echo(" Then open http://localhost:8501 in your browser.")
593+
click.echo(f" Log in with username: {username}")
594+
595+
475596
@cli.command("setup-system-db", help="Use to initialize the TestGen system database.")
476597
@click.option(
477598
"--delete-db",
@@ -728,6 +849,15 @@ def init_ui():
728849
init_ui()
729850

730851
app_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui/app.py")
852+
853+
# In standalone mode, pass the pgserver URI to the Streamlit subprocess
854+
# so it can connect without acquiring the pgserver file lock.
855+
child_env = {**os.environ, "TG_JOB_SOURCE": "UI"}
856+
if is_standalone_mode():
857+
server_uri = get_server_uri()
858+
if server_uri:
859+
child_env = {**os.environ, "TG_JOB_SOURCE": "UI", STANDALONE_URI_ENV_VAR: server_uri}
860+
731861
process= subprocess.Popen(
732862
[ # noqa: S607
733863
"streamlit",
@@ -742,7 +872,7 @@ def init_ui():
742872
"--",
743873
f"{'--debug' if settings.IS_DEBUG else ''}",
744874
],
745-
env={**os.environ, "TG_JOB_SOURCE": "UI"}
875+
env=child_env,
746876
)
747877
def term_ui(signum, _):
748878
LOG.info(f"Sending termination signal {signum} to Testgen UI")

testgen/commands/run_launch_db_config.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from testgen import settings
55
from testgen.common import create_database, execute_db_queries
66
from testgen.common.credentials import get_tg_db, get_tg_schema
7+
from testgen.common.standalone_postgres import get_home_dir, is_standalone_mode
78
from testgen.common.database.database_service import get_queries_for_command
89
from testgen.common.encrypt import EncryptText, encrypt_ui_password
910
from testgen.common.models import with_database_session
@@ -22,6 +23,14 @@ def _get_latest_revision_number():
2223
def _get_params_mapping() -> dict:
2324
ui_user_encrypted_password = encrypt_ui_password(settings.PASSWORD)
2425

26+
project_host = settings.PROJECT_DATABASE_HOST
27+
project_user = settings.PROJECT_DATABASE_USER
28+
project_password = settings.PROJECT_DATABASE_PASSWORD
29+
if is_standalone_mode():
30+
project_host = str(get_home_dir() / "pgdata")
31+
project_user = "postgres"
32+
project_password = ""
33+
2534
return {
2635
"UI_USER_NAME": settings.USERNAME,
2736
"UI_USER_USERNAME": settings.USERNAME,
@@ -33,10 +42,10 @@ def _get_params_mapping() -> dict:
3342
"SQL_FLAVOR": settings.PROJECT_SQL_FLAVOR,
3443
"PROJECT_NAME": settings.PROJECT_NAME,
3544
"PROJECT_DB": settings.PROJECT_DATABASE_NAME,
36-
"PROJECT_USER": settings.PROJECT_DATABASE_USER,
45+
"PROJECT_USER": project_user,
3746
"PROJECT_PORT": settings.PROJECT_DATABASE_PORT,
38-
"PROJECT_HOST": settings.PROJECT_DATABASE_HOST,
39-
"PROJECT_PW_ENCRYPTED": EncryptText(settings.PROJECT_DATABASE_PASSWORD),
47+
"PROJECT_HOST": project_host,
48+
"PROJECT_PW_ENCRYPTED": EncryptText(project_password),
4049
"PROJECT_HTTP_PATH": "",
4150
"PROJECT_SERVICE_ACCOUNT_KEY": "",
4251
"PROJECT_SCHEMA": settings.PROJECT_DATABASE_SCHEMA,

testgen/commands/run_quick_start.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from testgen import settings
1010
from testgen.commands.run_launch_db_config import get_app_db_params_mapping, run_launch_db_config
11+
from testgen.common.standalone_postgres import get_home_dir, is_standalone_mode
1112
from testgen.commands.test_generation import run_monitor_generation
1213
from testgen.common.credentials import get_tg_schema
1314
from testgen.common.database.database_service import (
@@ -93,14 +94,22 @@ def _prepare_connection_to_target_database(params_mapping):
9394

9495

9596
def _get_settings_params_mapping() -> dict:
97+
host = settings.PROJECT_DATABASE_HOST
98+
admin_user = settings.DATABASE_ADMIN_USER
99+
admin_password = settings.DATABASE_ADMIN_PASSWORD
100+
if is_standalone_mode():
101+
host = str(get_home_dir() / "pgdata")
102+
admin_user = "postgres"
103+
admin_password = ""
104+
96105
return {
97-
"TESTGEN_ADMIN_USER": settings.DATABASE_ADMIN_USER,
98-
"TESTGEN_ADMIN_PASSWORD": settings.DATABASE_ADMIN_PASSWORD,
106+
"TESTGEN_ADMIN_USER": admin_user,
107+
"TESTGEN_ADMIN_PASSWORD": admin_password,
99108
"SCHEMA_NAME": get_tg_schema(),
100109
"PROJECT_DB": settings.PROJECT_DATABASE_NAME,
101110
"PROJECT_SCHEMA": settings.PROJECT_DATABASE_SCHEMA,
102111
"PROJECT_KEY": settings.PROJECT_KEY,
103-
"PROJECT_DB_HOST": settings.PROJECT_DATABASE_HOST,
112+
"PROJECT_DB_HOST": host,
104113
"PROJECT_DB_PORT": settings.PROJECT_DATABASE_PORT,
105114
"SQL_FLAVOR": settings.PROJECT_SQL_FLAVOR,
106115
}

testgen/common/database/database_service.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
SQLFlavor,
3333
resolve_connection_params,
3434
)
35+
from testgen.common.standalone_postgres import get_connection_string as get_standalone_connection_string, is_standalone_mode
3536
from testgen.common.read_file import get_template_files
3637
from testgen.utils import get_exception_message
3738

@@ -370,16 +371,27 @@ def _init_app_db_connection(
370371
engine = engine_cache.app_db
371372

372373
if not engine:
373-
user = user_override if is_admin else get_tg_username()
374-
password = password_override if (is_admin or password_override is not None) else get_tg_password()
374+
if is_standalone_mode():
375+
connection_string = get_standalone_connection_string(database_name)
376+
else:
377+
user = user_override if is_admin else get_tg_username()
378+
password = password_override if (is_admin or password_override is not None) else get_tg_password()
375379

376-
# STANDARD FORMAT: flavor://username:password@host:port/database
377-
connection_string = (
378-
f"postgresql://{user}:{quote_plus(password)}@{get_tg_host()}:{get_tg_port()}/{database_name}"
379-
)
380+
# STANDARD FORMAT: flavor://username:password@host:port/database
381+
connection_string = (
382+
f"postgresql://{user}:{quote_plus(password)}@{get_tg_host()}:{get_tg_port()}/{database_name}"
383+
)
380384
try:
381-
engine: Engine = create_engine(connection_string, connect_args={"connect_timeout": 3600})
382-
engine_cache.app_db = engine
385+
engine: Engine = create_engine(
386+
connection_string,
387+
connect_args={
388+
"connect_timeout": 3600,
389+
# Force UTC so TIMESTAMP-without-tz inserts aren't silently shifted.
390+
"options": "-c TimeZone=UTC",
391+
},
392+
)
393+
if user_type == "normal":
394+
engine_cache.app_db = engine
383395

384396
except SQLAlchemyError as e:
385397
raise ValueError(f"Failed to create engine for App database '{database_name}' (User type = {user_type})") from e
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1+
from urllib.parse import quote_plus
2+
3+
from testgen.common.database.flavor.flavor_service import ResolvedConnectionParams
14
from testgen.common.database.flavor.redshift_flavor_service import RedshiftFlavorService
25

36

47
class PostgresqlFlavorService(RedshiftFlavorService):
58

69
escaped_underscore = "\\_"
10+
11+
def get_connection_string_from_fields(self, params: ResolvedConnectionParams) -> str:
12+
if params.host.startswith("/"):
13+
# Unix socket path — use query-param format for psycopg2
14+
return f"{self.url_scheme}://{params.username}:{quote_plus(params.password)}@/{params.dbname}?host={params.host}"
15+
return super().get_connection_string_from_fields(params)

testgen/common/logs.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,18 @@ def configure_logging(
2929
logger.addHandler(console_handler)
3030

3131
if settings.LOG_TO_FILE:
32-
os.makedirs(settings.LOG_FILE_PATH, exist_ok=True)
33-
34-
file_handler = ConcurrentTimedRotatingFileHandler(
35-
get_log_full_path(),
36-
when="MIDNIGHT",
37-
interval=1,
38-
backupCount=int(settings.LOG_FILE_MAX_QTY),
39-
)
40-
file_handler.setFormatter(formatter)
41-
logger.addHandler(file_handler)
32+
try:
33+
os.makedirs(settings.LOG_FILE_PATH, exist_ok=True)
34+
file_handler = ConcurrentTimedRotatingFileHandler(
35+
get_log_full_path(),
36+
when="MIDNIGHT",
37+
interval=1,
38+
backupCount=int(settings.LOG_FILE_MAX_QTY),
39+
)
40+
file_handler.setFormatter(formatter)
41+
logger.addHandler(file_handler)
42+
except OSError:
43+
logger.warning("Cannot write logs to %s — file logging disabled", settings.LOG_FILE_PATH)
4244

4345

4446
def get_log_full_path() -> str:

testgen/common/models/__init__.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@
55
import urllib.parse
66

77
from sqlalchemy import create_engine
8-
from sqlalchemy.ext.declarative import declarative_base
9-
from sqlalchemy.orm import Session as SQLAlchemySession
10-
from sqlalchemy.orm import sessionmaker
8+
from sqlalchemy.orm import DeclarativeBase, Session as SQLAlchemySession, sessionmaker
119

1210
from testgen import settings
1311

@@ -19,11 +17,17 @@
1917
echo=False,
2018
connect_args={
2119
"application_name": platform.node(),
22-
"options": f"-csearch_path={settings.DATABASE_SCHEMA}",
20+
# TimeZone=UTC so TIMESTAMP (no-tz) columns store aware UTC datetimes as-is.
21+
# Without this, pgserver inherits the OS TZ and silently shifts
22+
# timestamps on insert, which make_json_safe then re-reads as UTC.
23+
"options": f"-csearch_path={settings.DATABASE_SCHEMA} -c TimeZone=UTC",
2324
},
2425
)
2526

26-
Base = declarative_base()
27+
class Base(DeclarativeBase):
28+
# Allow legacy Column() + type-hint patterns without Mapped[].
29+
# Can be removed once all models use Mapped[] annotations.
30+
__allow_unmapped__ = True
2731

2832
Session = sessionmaker(
2933
engine,

0 commit comments

Comments
 (0)