1+ import base64
2+ import importlib
13import logging
24import os
5+ import platform
6+ import secrets
37import signal
48import subprocess
59import sys
610from dataclasses import dataclass , field
711from datetime import UTC , datetime , timedelta
12+ from importlib .metadata import version as pkg_version
13+ from pathlib import Path
814
915import click
1016from click .core import Context
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+ )
4558from testgen .common .models import with_database_session
4659from testgen .common .models .profiling_run import ProfilingRun
4760from testgen .common .models .settings import PersistedSetting
@@ -99,19 +112,23 @@ def invoke(self, ctx: Context):
99112)
100113@click .pass_context
101114def 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" )
0 commit comments