diff --git a/.github/assets/templates/agent.yml b/.github/assets/templates/agent.yml index 3a3cb1c..3ae5b3a 100644 --- a/.github/assets/templates/agent.yml +++ b/.github/assets/templates/agent.yml @@ -1,17 +1,15 @@ -name: ${PROJECT_NAME} services: - app: - container_name: ${PROJECT_NAME}-app - restart: always + agent: + restart: unless-stopped image: portabase/agent:latest volumes: - ./databases.json:/config/config.json - extra_hosts: - - "localhost:host-gateway" environment: - TZ: "Europe/Paris" + TZ: "${TZ}" EDGE_KEY: "${EDGE_KEY}" - LOG: info + LOG_LEVEL: "${LOG_LEVEL}" + POLLING: "${POLLING}" + DATA_PATH: "${DATA_PATH}" networks: - portabase @@ -22,4 +20,4 @@ services: networks: portabase: name: portabase_network - external: true \ No newline at end of file + external: true diff --git a/.github/assets/templates/dashboard.yml b/.github/assets/templates/dashboard.yml index 1b527b5..5d27087 100644 --- a/.github/assets/templates/dashboard.yml +++ b/.github/assets/templates/dashboard.yml @@ -1,35 +1,46 @@ name: ${PROJECT_NAME} services: - portabase: - container_name: ${PROJECT_NAME}-app - image: portabase/portabase:latest - env_file: - - .env - ports: - - "${HOST_PORT}:80" - environment: - - TIME_ZONE=Europe/Paris - volumes: - - portabase-data:/data - depends_on: - db: - condition: service_healthy - db: - container_name: ${PROJECT_NAME}-pg - image: postgres:17-alpine - ports: - - "${PG_PORT}:5432" - volumes: - - postgres-data:/var/lib/postgresql/data - environment: - - POSTGRES_DB=${POSTGRES_DB} - - POSTGRES_USER=${POSTGRES_USER} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s - timeout: 5s - retries: 5 + portabase: + container_name: ${PROJECT_NAME}-app + image: portabase/portabase:latest + restart: unless-stopped + env_file: + - .env + ports: + - "${HOST_PORT}:80" + environment: + - TZ=${TZ} + - LOG_LEVEL=${LOG_LEVEL} + - PROJECT_SECRET=${PROJECT_SECRET} + - PROJECT_URL=${PROJECT_URL} + volumes: + - portabase-data:/data + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost/api/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + db: + container_name: ${PROJECT_NAME}-pg + image: postgres:17-alpine + restart: unless-stopped + ports: + - "${PG_PORT}:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 volumes: - postgres-data: - portabase-data: + postgres-data: + portabase-data: diff --git a/CITATION.cff b/CITATION.cff index 919d683..fa19899 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -23,4 +23,4 @@ keywords: - integration license: Apache-2.0 version: 26.04.1 -date-released: "2026-04-20" \ No newline at end of file +date-released: "2026-04-20" diff --git a/commands/agent.py b/commands/agent.py index dfc5912..28e0190 100644 --- a/commands/agent.py +++ b/commands/agent.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Optional +import questionary import typer from rich.panel import Panel from rich.prompt import Confirm, IntPrompt, Prompt @@ -15,9 +16,11 @@ from core.utils import ( check_system, console, + generate_password, get_free_port, get_random_hint, print_banner, + questionary_style, validate_edge_key, ) from templates.compose import ( @@ -25,7 +28,12 @@ AGENT_MARIADB_SNIPPET, AGENT_MONGODB_AUTH_SNIPPET, AGENT_MONGODB_SNIPPET, + AGENT_MSSQL_SNIPPET, AGENT_POSTGRES_SNIPPET, + AGENT_REDIS_AUTH_SNIPPET, + AGENT_REDIS_SNIPPET, + AGENT_VALKEY_AUTH_SNIPPET, + AGENT_VALKEY_SNIPPET, ) @@ -34,10 +42,10 @@ def agent( key: Optional[str] = typer.Option(None, "--key", "-k", help="Edge Key"), tz: str = typer.Option("UTC", "--tz", help="Timezone"), polling: int = typer.Option(5, "--polling", help="Polling frequency in seconds"), - env: str = typer.Option("production", "--env", help="Application environment"), data_path: str = typer.Option("/data", "--data-path", help="Internal data path"), start: bool = typer.Option(False, "--start", "-s", help="Start immediately"), ): + print_banner() check_system() ensure_network("portabase_network") @@ -49,7 +57,6 @@ def agent( raise typer.Exit() path.mkdir(parents=True, exist_ok=True) - project_name = name.lower().replace(" ", "-") if not key: key = Prompt.ask("[key]Edge Key[/key]") @@ -66,13 +73,6 @@ def agent( if polling == 5: polling = IntPrompt.ask("Polling frequency (seconds)", default=5) - if env == "production": - env = Prompt.ask( - "Environment", - choices=["production", "staging", "development"], - default="production", - ) - if data_path == "/data": data_path = Prompt.ask("Internal Data Path", default="/data") @@ -96,11 +96,10 @@ def agent( env_vars = { "EDGE_KEY": key, - "PROJECT_NAME": project_name, "TZ": tz, "POLLING": str(polling), - "APP_ENV": env, "DATA_PATH": data_path, + "LOG_LEVEL": "info", } extra_services = "" @@ -120,218 +119,215 @@ def agent( console.print(Panel("[bold]Database Setup[/bold]", style="cyan")) while Confirm.ask("Do you want to configure a database?", default=True): - mode = Prompt.ask( - "Configuration Mode", choices=["new", "existing"], default="new" - ) + while True: + mode = Prompt.ask( + "Configuration Mode", choices=["new", "existing"], default="new" + ) - if mode == "existing": - console.print("[info]External/Existing Database Configuration[/info]") - category = Prompt.ask("Category", choices=["SQL", "NoSQL"], default="SQL") + if mode == "existing": + console.print("[info]External/Existing Database Configuration[/info]") + db_type = questionary.select( + "Select Database Type", + choices=[ + "back", + "postgresql", + "mysql", + "mariadb", + "sqlite", + "firebird", + "mongodb", + "redis", + "valkey", + "mssql", + ], + style=questionary_style, + ).ask() + + if db_type == "back": + continue + + if not db_type: + raise typer.Exit() + + friendly_name = Prompt.ask("Display Name", default="External DB") + + if db_type == "sqlite": + db_name = Prompt.ask("Database Path (relative or absolute)") + if not db_name.startswith("/"): + app_volumes.append(f"./{db_name}:/config/{db_name}") + container_path = f"/config/{db_name}" + else: + container_path = db_name - if category == "SQL": - db_type = Prompt.ask( - "Type", - choices=["postgresql", "mysql", "mariadb", "sqlite", "firebird"], - default="postgresql", - ) - else: - db_type = Prompt.ask( - "Type", - choices=["mongodb"], - default="mongodb", - ) + add_db_to_json( + path, + { + "name": friendly_name, + "database": container_path, + "type": db_type, + "generated_id": str(uuid.uuid4()), + }, + ) + else: + db_name = Prompt.ask("Database Name") + host = Prompt.ask("Host", default="localhost") + port = IntPrompt.ask( + "Port", + default=5432 + if db_type == "postgresql" + else ( + 3050 + if db_type == "firebird" + else ( + 1433 + if db_type == "mssql" + else ( + 3306 if db_type in ["mysql", "mariadb"] else 27017 + ) + ) + ), + ) + user = Prompt.ask("Username") + password = Prompt.ask("Password", password=True) - friendly_name = Prompt.ask("Display Name", default="External DB") + add_db_to_json( + path, + { + "name": friendly_name, + "database": db_name, + "type": db_type, + "username": user, + "password": password, + "port": port, + "host": host, + "generated_id": str(uuid.uuid4()), + }, + ) + console.print("[success]✔ Added to config[/success]") + break - if db_type == "sqlite": - db_name = Prompt.ask("Database Path (relative or absolute)") - if not db_name.startswith("/"): - app_volumes.append(f"./{db_name}:/config/{db_name}") - container_path = f"/config/{db_name}" - else: - container_path = db_name - - add_db_to_json( - path, - { - "name": friendly_name, - "database": container_path, - "type": db_type, - "generated_id": str(uuid.uuid4()), - }, - ) else: - db_name = Prompt.ask("Database Name") - host = Prompt.ask("Host", default="localhost") - port = IntPrompt.ask( - "Port", - default=5432 - if db_type == "postgresql" - else ( - 3050 - if db_type == "firebird" - else (3306 if db_type in ["mysql", "mariadb"] else 27017) - ), - ) - user = Prompt.ask("Username") - password = Prompt.ask("Password", password=True) - - add_db_to_json( - path, - { - "name": friendly_name, - "database": db_name, - "type": db_type, - "username": user, - "password": password, - "port": port, - "host": host, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print("[success]✔ Added to config[/success]") + console.print("[info]New Local Docker Container[/info]") + db_engine = questionary.select( + "Select Database Engine", + choices=[ + "back", + "postgresql", + "mysql", + "mariadb", + "sqlite", + "firebird", + "mongodb", + "redis", + "valkey", + "mssql", + ], + style=questionary_style, + ).ask() + + if db_engine == "back": + continue + + if not db_engine: + raise typer.Exit() + + db_variant = "no-auth" + if db_engine in ["mongodb", "redis", "valkey"]: + engine_display = { + "mongodb": "MongoDB", + "redis": "Redis", + "valkey": "Valkey", + }[db_engine] + db_variant = questionary.select( + f"Select {engine_display} Variant", + choices=["back", "no-auth", "with-auth"], + style=questionary_style, + ).ask() + + if db_variant == "back": + continue + + if not db_variant: + raise typer.Exit() + + if db_engine == "sqlite": + db_name = Prompt.ask("Database Name", default="local") + if not db_name.endswith(".sqlite"): + db_name += ".sqlite" - else: - console.print("[info]New Local Docker Container[/info]") - category = Prompt.ask("Category", choices=["SQL", "NoSQL"], default="SQL") - - if category == "SQL": - db_engine = Prompt.ask( - "Engine", - choices=["postgresql", "mysql", "mariadb", "sqlite", "firebird"], - default="postgresql", - ) - db_variant = "standard" - else: - db_engine = Prompt.ask( - "Engine", - choices=["mongodb"], - default="mongodb", - ) - db_variant = Prompt.ask( - "Type", choices=["standard", "with-auth"], default="standard" - ) + app_volumes.append(f"./{db_name}:/config/{db_name}") - if db_engine == "sqlite": - db_name = Prompt.ask("Database Name", default="local") - if not db_name.endswith(".sqlite"): - db_name += ".sqlite" - - app_volumes.append(f"./{db_name}:/config/{db_name}") - - add_db_to_json( - path, - { - "name": db_name, - "database": f"/config/{db_name}", - "type": "sqlite", - "generated_id": str(uuid.uuid4()), - }, - ) - console.print(f"[success]✔ Added SQLite database ({db_name})[/success]") - - elif db_engine == "postgresql": - pg_port = get_free_port() - db_user = "admin" - db_pass = secrets.token_hex(8) - db_name = f"pg_{secrets.token_hex(4)}" - service_name = f"db-pg-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(pg_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = ( - AGENT_POSTGRES_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) + add_db_to_json( + path, + { + "name": db_name, + "database": f"/config/{db_name}", + "type": "sqlite", + "generated_id": str(uuid.uuid4()), + }, + ) + console.print( + f"[success]✔ Added SQLite database ({db_name})[/success]" + ) - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": db_name, - "type": "postgresql", - "username": db_user, - "password": db_pass, - "port": pg_port, - "host": "localhost", - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added Postgres container (Port {pg_port})[/success]" - ) + elif db_engine == "postgresql": + pg_port = get_free_port() + db_user = "admin" + db_pass = generate_password(16) + db_name = f"pg_{secrets.token_hex(4)}" + service_name = f"db-pg-{secrets.token_hex(2)}" - elif db_engine == "mariadb" or db_engine == "mysql": - mysql_port = get_free_port() - db_user = "admin" - db_pass = secrets.token_hex(8) - db_name = f"mysql_{secrets.token_hex(4)}" - service_name = f"db-mariadb-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(mysql_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = ( - AGENT_MARIADB_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(pg_port) + env_vars[f"{var_prefix}_DB"] = db_name + env_vars[f"{var_prefix}_USER"] = db_user + env_vars[f"{var_prefix}_PASS"] = db_pass - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": db_name, - "type": db_engine, - "username": db_user, - "password": db_pass, - "port": mysql_port, - "host": "localhost", - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added MariaDB container (Port {mysql_port})[/success]" - ) + snippet = ( + AGENT_POSTGRES_SNIPPET.replace("${SERVICE_NAME}", service_name) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + .replace("${USER}", f"${{{var_prefix}_USER}}") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + ) + + extra_services += snippet + volumes_list.append(f"{service_name}-data") + + add_db_to_json( + path, + { + "name": db_name, + "database": db_name, + "type": "postgresql", + "username": db_user, + "password": db_pass, + "port": 5432, + "host": service_name, + "generated_id": str(uuid.uuid4()), + }, + ) - elif db_engine == "mongodb": - if db_variant == "with-auth": - mongo_port = get_free_port() + console.print( + f"[success]✔ Added Postgres container (Port {pg_port})[/success]" + ) + + elif db_engine == "mariadb" or db_engine == "mysql": + mysql_port = get_free_port() db_user = "admin" - db_pass = secrets.token_hex(8) - db_name = f"mongo_{secrets.token_hex(4)}" - service_name = f"db-mongo-auth-{secrets.token_hex(2)}" + db_pass = generate_password(16) + db_name = f"mysql_{secrets.token_hex(4)}" + service_name = f"db-mariadb-{secrets.token_hex(2)}" var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(mongo_port) + env_vars[f"{var_prefix}_PORT"] = str(mysql_port) env_vars[f"{var_prefix}_DB"] = db_name env_vars[f"{var_prefix}_USER"] = db_user env_vars[f"{var_prefix}_PASS"] = db_pass snippet = ( - AGENT_MONGODB_AUTH_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) + AGENT_MARIADB_SNIPPET.replace("${SERVICE_NAME}", service_name) .replace("${PORT}", f"${{{var_prefix}_PORT}}") .replace("${VOL_NAME}", f"{service_name}-data") .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") @@ -347,32 +343,274 @@ def agent( { "name": db_name, "database": db_name, - "type": "mongodb", + "type": db_engine, "username": db_user, "password": db_pass, - "port": mongo_port, - "host": "localhost", + "port": 3306, + "host": service_name, "generated_id": str(uuid.uuid4()), }, ) console.print( - f"[success]✔ Added MongoDB Auth container (Port {mongo_port})[/success]" + f"[success]✔ Added MariaDB container (Port {mysql_port})[/success]" ) - else: - mongo_port = get_free_port() - db_name = f"mongo_{secrets.token_hex(4)}" - service_name = f"db-mongo-{secrets.token_hex(2)}" + + elif db_engine == "mongodb": + if db_variant == "with-auth": + mongo_port = get_free_port() + db_user = "admin" + db_pass = generate_password(16) + db_name = f"mongo_{secrets.token_hex(4)}" + service_name = f"db-mongo-auth-{secrets.token_hex(2)}" + + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(mongo_port) + env_vars[f"{var_prefix}_DB"] = db_name + env_vars[f"{var_prefix}_USER"] = db_user + env_vars[f"{var_prefix}_PASS"] = db_pass + + snippet = ( + AGENT_MONGODB_AUTH_SNIPPET.replace( + "${SERVICE_NAME}", service_name + ) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + .replace("${USER}", f"${{{var_prefix}_USER}}") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + ) + + extra_services += snippet + volumes_list.append(f"{service_name}-data") + + add_db_to_json( + path, + { + "name": db_name, + "database": db_name, + "type": "mongodb", + "username": db_user, + "password": db_pass, + "port": 27017, + "host": service_name, + "generated_id": str(uuid.uuid4()), + }, + ) + console.print( + f"[success]✔ Added MongoDB Auth container (Port {mongo_port})[/success]" + ) + else: + mongo_port = get_free_port() + db_name = f"mongo_{secrets.token_hex(4)}" + service_name = f"db-mongo-{secrets.token_hex(2)}" + + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(mongo_port) + env_vars[f"{var_prefix}_DB"] = db_name + + snippet = ( + AGENT_MONGODB_SNIPPET.replace( + "${SERVICE_NAME}", service_name + ) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + ) + extra_services += snippet + volumes_list.append(f"{service_name}-data") + + add_db_to_json( + path, + { + "name": db_name, + "database": db_name, + "type": "mongodb", + "username": "", + "password": "", + "port": 27017, + "host": service_name, + "generated_id": str(uuid.uuid4()), + }, + ) + + console.print( + f"[success]✔ Added MongoDB container (Port {mongo_port})[/success]" + ) + + elif db_engine == "redis": + if db_variant == "with-auth": + redis_port = get_free_port() + db_name = f"redis_{secrets.token_hex(4)}" + service_name = f"db-redis-auth-{secrets.token_hex(2)}" + db_pass = generate_password(16) + + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(redis_port) + env_vars[f"{var_prefix}_PASS"] = db_pass + + snippet = ( + AGENT_REDIS_AUTH_SNIPPET.replace( + "${SERVICE_NAME}", service_name + ) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + ) + + extra_services += snippet + volumes_list.append(f"{service_name}-data") + + add_db_to_json( + path, + { + "name": db_name, + "database": "0", + "type": "redis", + "username": "", + "password": db_pass, + "port": 6379, + "host": service_name, + "generated_id": str(uuid.uuid4()), + }, + ) + console.print( + f"[success]✔ Added Redis Auth container (Port {redis_port})[/success]" + ) + else: + redis_port = get_free_port() + db_name = f"redis_{secrets.token_hex(4)}" + service_name = f"db-redis-{secrets.token_hex(2)}" + + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(redis_port) + + snippet = ( + AGENT_REDIS_SNIPPET.replace("${SERVICE_NAME}", service_name) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + ) + + extra_services += snippet + volumes_list.append(f"{service_name}-data") + + add_db_to_json( + path, + { + "name": db_name, + "database": "0", + "type": "redis", + "username": "", + "password": "", + "port": 6379, + "host": service_name, + "generated_id": str(uuid.uuid4()), + }, + ) + console.print( + f"[success]✔ Added Redis container (Port {redis_port})[/success]" + ) + + elif db_engine == "valkey": + if db_variant == "with-auth": + valkey_port = get_free_port() + db_name = f"valkey_{secrets.token_hex(4)}" + service_name = f"db-valkey-auth-{secrets.token_hex(2)}" + db_pass = generate_password(16) + + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(valkey_port) + env_vars[f"{var_prefix}_PASS"] = db_pass + + snippet = ( + AGENT_VALKEY_AUTH_SNIPPET.replace( + "${SERVICE_NAME}", service_name + ) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + ) + + extra_services += snippet + volumes_list.append(f"{service_name}-data") + + add_db_to_json( + path, + { + "name": db_name, + "database": "0", + "type": "valkey", + "username": "", + "password": db_pass, + "port": 6379, + "host": service_name, + "generated_id": str(uuid.uuid4()), + }, + ) + console.print( + f"[success]✔ Added Valkey Auth container (Port {valkey_port})[/success]" + ) + else: + valkey_port = get_free_port() + db_name = f"valkey_{secrets.token_hex(4)}" + service_name = f"db-valkey-{secrets.token_hex(2)}" + + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(valkey_port) + + snippet = ( + AGENT_VALKEY_SNIPPET.replace( + "${SERVICE_NAME}", service_name + ) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + ) + + extra_services += snippet + volumes_list.append(f"{service_name}-data") + + add_db_to_json( + path, + { + "name": db_name, + "database": "0", + "type": "valkey", + "username": "", + "password": "", + "port": 6379, + "host": service_name, + "generated_id": str(uuid.uuid4()), + }, + ) + console.print( + f"[success]✔ Added Valkey container (Port {valkey_port})[/success]" + ) + + elif db_engine == "firebird": + fb_port = get_free_port() + db_user = "alice" + db_pass = generate_password(16) + db_root_pass = generate_password(16) + db_name = "mirror.fdb" + db_container_path = f"/var/lib/firebird/data/{db_name}" + service_name = f"db-firebird-{secrets.token_hex(2)}" var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(mongo_port) + env_vars[f"{var_prefix}_PORT"] = str(fb_port) env_vars[f"{var_prefix}_DB"] = db_name + env_vars[f"{var_prefix}_USER"] = db_user + env_vars[f"{var_prefix}_PASS"] = db_pass + env_vars[f"{var_prefix}_ROOT_PASS"] = db_root_pass snippet = ( - AGENT_MONGODB_SNIPPET.replace("${SERVICE_NAME}", service_name) + AGENT_FIREBIRD_SNIPPET.replace("${SERVICE_NAME}", service_name) .replace("${PORT}", f"${{{var_prefix}_PORT}}") .replace("${VOL_NAME}", f"{service_name}-data") .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + .replace("${USER}", f"${{{var_prefix}_USER}}") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + .replace("${ROOT_PASSWORD}", f"${{{var_prefix}_ROOT_PASS}}") ) + extra_services += snippet volumes_list.append(f"{service_name}-data") @@ -380,60 +618,56 @@ def agent( path, { "name": db_name, - "database": db_name, - "type": "mongodb", - "username": "", - "password": "", - "port": mongo_port, - "host": "localhost", + "database": db_container_path, + "type": "firebird", + "username": db_user, + "password": db_pass, + "port": 3050, + "host": service_name, "generated_id": str(uuid.uuid4()), }, ) console.print( - f"[success]✔ Added MongoDB container (Port {mongo_port})[/success]" + f"[success]✔ Added Firebird container (Port {fb_port})[/success]" ) - elif db_engine == "firebird": - fb_port = get_free_port() - db_user = "alice" - db_pass = secrets.token_hex(8) - db_name = "mirror.fdb" - service_name = f"db-firebird-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(fb_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = ( - AGENT_FIREBIRD_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) + elif db_engine == "mssql": + mssql_port = get_free_port() + db_pass = generate_password(16) + db_name = "master" + service_name = f"db-mssql-{secrets.token_hex(2)}" - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": db_name, - "type": "firebird", - "username": db_user, - "password": db_pass, - "port": fb_port, - "host": "localhost", - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added Firebird container (Port {fb_port})[/success]" - ) + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(mssql_port) + env_vars[f"{var_prefix}_PASS"] = db_pass + + snippet = ( + AGENT_MSSQL_SNIPPET.replace("${SERVICE_NAME}", service_name) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + ) + + extra_services += snippet + volumes_list.append(f"{service_name}-data") + + add_db_to_json( + path, + { + "name": "MSSQL", + "database": db_name, + "type": "mssql", + "username": "sa", + "password": db_pass, + "port": 1433, + "host": service_name, + "generated_id": str(uuid.uuid4()), + }, + ) + console.print( + f"[success]✔ Added MSSQL container (Port {mssql_port})[/success]" + ) + break if volumes_list: for vol in volumes_list: @@ -441,7 +675,6 @@ def agent( final_compose = raw_template.replace("{{EXTRA_SERVICES}}", extra_services) final_compose = final_compose.replace("{{EXTRA_VOLUMES}}", extra_volumes) - final_compose = final_compose.replace("${PROJECT_NAME}", project_name) vols_str = "\n".join([f" - {v}" for v in app_volumes]) final_compose = final_compose.replace( @@ -453,12 +686,10 @@ def agent( summary.add_column("Value", style="white") summary.add_row("Agent Name", name) - summary.add_row("Project ID", project_name) summary.add_row("Path", str(path)) summary.add_row("Edge Key", f"{key[:10]}...{key[-10:]}" if len(key) > 20 else key) summary.add_row("Timezone", tz) summary.add_row("Polling", f"{polling}s") - summary.add_row("Environment", env) db_config = load_db_config(path) dbs = db_config.get("databases", []) diff --git a/commands/dashboard.py b/commands/dashboard.py index 4ddd863..53115e1 100644 --- a/commands/dashboard.py +++ b/commands/dashboard.py @@ -2,6 +2,7 @@ import secrets from pathlib import Path +import questionary import typer from rich.panel import Panel from rich.prompt import Confirm, IntPrompt, Prompt @@ -13,9 +14,11 @@ from core.utils import ( check_system, console, + generate_password, get_free_port, get_random_hint, print_banner, + questionary_style, ) @@ -46,15 +49,35 @@ def dashboard( "PROJECT_SECRET": auth_secret, "PROJECT_URL": base_url, "PROJECT_NAME": project_name, + "TZ": "Europe/Paris", + "LOG_LEVEL": "info", } - mode = Prompt.ask( - "Database Setup", choices=["internal", "external"], default="internal" - ) + mode = questionary.select( + "Database Setup", + choices=[ + questionary.Choice( + "external: create a dedicated container in the same docker-compose.yml (recommended)", + value="external", + ), + questionary.Choice( + "internal: use the database embedded in the Portabase container", + value="internal", + ), + questionary.Choice( + "custom: provide credentials of an existing database", + value="custom", + ), + ], + style=questionary_style, + ).ask() + + if not mode: + raise typer.Exit() - if mode == "internal": + if mode == "external": pg_port = get_free_port() - pg_pass = secrets.token_hex(16) + pg_pass = generate_password(16) env_vars.update( { "POSTGRES_DB": "portabase", @@ -66,7 +89,7 @@ def dashboard( } ) final_compose = raw_template.replace("${PROJECT_NAME}", project_name) - else: + elif mode == "custom": console.print("[info]External Database Configuration[/info]") db_host = Prompt.ask("Host", default="localhost") db_port = IntPrompt.ask("Port", default=5432) @@ -93,6 +116,15 @@ def dashboard( ) final_compose = re.sub(r"[ ]{4}postgres-data:\n", "", final_compose) final_compose = final_compose.replace("${PROJECT_NAME}", project_name) + else: + final_compose = re.sub( + r"[ ]{8}depends_on:.*?service_healthy\n", "", raw_template, flags=re.DOTALL + ) + final_compose = re.sub( + r"[ ]{4}db:.*?retries: 5\n", "", final_compose, flags=re.DOTALL + ) + final_compose = re.sub(r"[ ]{4}postgres-data:\n", "", final_compose) + final_compose = final_compose.replace("${PROJECT_NAME}", project_name) summary = Table(show_header=False, box=None, padding=(0, 2)) summary.add_column("Property", style="bold cyan") @@ -101,16 +133,17 @@ def dashboard( summary.add_row("Dashboard Name", name) summary.add_row("Path", str(path)) summary.add_row("Access URL", f"[bold green]http://localhost:{port}[/bold green]") - summary.add_row( - "Database Setup", - "All-in-one (Internal Docker DB)" - if mode == "internal" - else "Custom (External Database)", - ) - if mode == "internal": + db_setup_label = { + "external": "Dedicated Docker Container (Recommended)", + "internal": "Embedded Database (In-container)", + "custom": "Custom/Existing Database", + } + summary.add_row("Database Setup", db_setup_label.get(mode)) + + if mode == "external": summary.add_row("Internal Port", env_vars["PG_PORT"]) - else: + elif mode == "custom": summary.add_row("DB Host", env_vars["POSTGRES_HOST"]) summary.add_row("DB Name", env_vars["POSTGRES_DB"]) masked_url = re.sub(r":.*?@", ":****@", env_vars["DATABASE_URL"]) @@ -140,11 +173,14 @@ def dashboard( write_file(path / "docker-compose.yml", final_compose) write_env_file(path, env_vars) - db_info = ( - f"\n[dim]DB Port: {env_vars.get('PG_PORT')}[/dim]" - if mode == "internal" - else f"\n[dim]External DB: {env_vars.get('POSTGRES_HOST')}[/dim]" - ) + db_info = "" + if mode == "external": + db_info = f"\n[dim]DB Port: {env_vars.get('PG_PORT')}[/dim]" + elif mode == "custom": + db_info = f"\n[dim]External DB: {env_vars.get('POSTGRES_HOST')}[/dim]" + else: + db_info = "\n[dim]Embedded Database[/dim]" + console.print( Panel( f"[bold white]DASHBOARD CREATED: {name}[/bold white]\n[dim]Path: {path}[/dim]{db_info}", diff --git a/commands/db.py b/commands/db.py index a01f9e8..174cdd5 100644 --- a/commands/db.py +++ b/commands/db.py @@ -1,20 +1,34 @@ +import re import secrets import uuid from pathlib import Path +import questionary import typer from rich.panel import Panel from rich.prompt import IntPrompt, Prompt from rich.table import Table from core.config import add_db_to_json, load_db_config, save_db_config, write_env_file -from core.utils import console, get_free_port, validate_work_dir +from core.docker import ensure_network +from core.utils import ( + console, + generate_password, + get_free_port, + questionary_style, + validate_work_dir, +) from templates.compose import ( AGENT_FIREBIRD_SNIPPET, AGENT_MARIADB_SNIPPET, AGENT_MONGODB_AUTH_SNIPPET, AGENT_MONGODB_SNIPPET, + AGENT_MSSQL_SNIPPET, AGENT_POSTGRES_SNIPPET, + AGENT_REDIS_AUTH_SNIPPET, + AGENT_REDIS_SNIPPET, + AGENT_VALKEY_AUTH_SNIPPET, + AGENT_VALKEY_SNIPPET, ) app = typer.Typer(help="Manage databases configuration.") @@ -64,267 +78,414 @@ def list_dbs(name: str = typer.Argument(..., help="Name of the agent")): def add_db(name: str = typer.Argument(..., help="Name of the agent")): path = Path(name).resolve() validate_work_dir(path) + ensure_network("portabase_network") console.print(Panel("Add Database to Agent", style="bold blue")) - mode = Prompt.ask( - "Configuration Mode", choices=["new", "existing"], default="existing" - ) - category = Prompt.ask("Category", choices=["SQL", "NoSQL"], default="SQL") - - if mode == "existing": - if category == "SQL": - db_type = Prompt.ask( - "Type", - choices=["postgresql", "mysql", "mariadb", "sqlite", "firebird"], - default="postgresql", - ) - else: - db_type = Prompt.ask("Type", choices=["mongodb"], default="mongodb") - - friendly_name = Prompt.ask("Display Name", default="External DB") - - if db_type == "sqlite": - db_name = Prompt.ask("Database Path (e.g. /data/db.sqlite)") - entry = { - "name": friendly_name, - "database": db_name, - "type": db_type, - "generated_id": str(uuid.uuid4()), - } - else: - db_name = Prompt.ask("Database Name") - host = Prompt.ask("Host", default="localhost") - port = IntPrompt.ask( - "Port", - default=5432 - if db_type == "postgresql" - else ( - 3050 - if db_type == "firebird" - else (3306 if db_type in ["mysql", "mariadb"] else 27017) - ), - ) - user = Prompt.ask("Username") - password = Prompt.ask("Password", password=True) - - entry = { - "name": friendly_name, - "database": db_name, - "type": db_type, - "username": user, - "password": password, - "port": port, - "host": host, - "generated_id": str(uuid.uuid4()), - } - - add_db_to_json(path, entry) - else: - if category == "SQL": - db_engine = Prompt.ask( - "Engine", - choices=["postgresql", "mysql", "mariadb", "sqlite", "firebird"], - default="postgresql", - ) - else: - db_engine = Prompt.ask("Engine", choices=["mongodb"], default="mongodb") - db_variant = Prompt.ask( - "Type", choices=["standard", "with-auth"], default="standard" - ) - - env_vars = {} - snippet = "" - service_name = "" - db_name = "" - db_user = "" - db_pass = "" - db_port = 0 - - if db_engine == "sqlite": - db_name = Prompt.ask("Database Name", default="local") - if not db_name.endswith(".sqlite"): - db_name += ".sqlite" - - compose_path = path / "docker-compose.yml" - if compose_path.exists(): - with open(compose_path, "r") as f: - lines = f.readlines() - - new_lines = [] - in_app_service = False - in_volumes = False - for line in lines: - new_lines.append(line) - if "app:" in line: - in_app_service = True - if in_app_service and "volumes:" in line: - in_volumes = True - if in_volumes and "- ./databases.json" in line: - new_lines.append(f" - ./{db_name}:/config/{db_name}\n") - in_volumes = False - in_app_service = False - - with open(compose_path, "w") as f: - f.writelines(new_lines) + while True: + mode = Prompt.ask( + "Configuration Mode", choices=["new", "existing"], default="existing" + ) - add_db_to_json( - path, - { - "name": db_name, - "database": f"/config/{db_name}", - "type": "sqlite", + if mode == "existing": + db_type = questionary.select( + "Select Database Type", + choices=[ + "back", + "postgresql", + "mysql", + "mariadb", + "sqlite", + "firebird", + "mongodb", + "mssql", + ], + style=questionary_style, + ).ask() + + if db_type == "back": + continue + + if not db_type: + raise typer.Exit() + + friendly_name = Prompt.ask("Display Name", default="External DB") + + if db_type == "sqlite": + db_name = Prompt.ask("Database Path (e.g. /data/db.sqlite)") + entry = { + "name": friendly_name, + "database": db_name, + "type": db_type, "generated_id": str(uuid.uuid4()), - }, - ) - console.print(f"[success]✔ Added SQLite database ({db_name})[/success]") - - elif db_engine == "postgresql": - db_port = get_free_port() - db_user = "admin" - db_pass = secrets.token_hex(8) - db_name = f"pg_{secrets.token_hex(4)}" - service_name = f"db-pg-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = ( - AGENT_POSTGRES_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) + } + else: + db_name = Prompt.ask("Database Name") + host = Prompt.ask("Host", default="localhost") + port = IntPrompt.ask( + "Port", + default=5432 + if db_type == "postgresql" + else ( + 3050 + if db_type == "firebird" + else ( + 1433 + if db_type == "mssql" + else (3306 if db_type in ["mysql", "mariadb"] else 27017) + ) + ), + ) + user = Prompt.ask("Username") + password = Prompt.ask("Password", password=True) + + entry = { + "name": friendly_name, + "database": db_name, + "type": db_type, + "username": user, + "password": password, + "port": port, + "host": host, + "generated_id": str(uuid.uuid4()), + } - elif db_engine in ["mysql", "mariadb"]: - db_port = get_free_port() - db_user = "admin" - db_pass = secrets.token_hex(8) - db_name = f"mysql_{secrets.token_hex(4)}" - service_name = f"db-mariadb-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = ( - AGENT_MARIADB_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) + add_db_to_json(path, entry) + break + else: + db_engine = questionary.select( + "Select Database Engine", + choices=[ + "back", + "postgresql", + "mysql", + "mariadb", + "sqlite", + "firebird", + "mongodb", + "redis", + "valkey", + "mssql", + ], + style=questionary_style, + ).ask() + + if db_engine == "back": + continue + + if not db_engine: + raise typer.Exit() + + db_variant = "no-auth" + if db_engine in ["mongodb", "redis", "valkey"]: + engine_display = { + "mongodb": "MongoDB", + "redis": "Redis", + "valkey": "Valkey", + }[db_engine] + db_variant = questionary.select( + f"Select {engine_display} Variant", + choices=["back", "no-auth", "with-auth"], + default="no-auth", + style=questionary_style, + ).ask() + + if db_variant == "back": + continue + + if not db_variant: + raise typer.Exit() + + env_vars = {} + snippet = "" + service_name = "" + db_name = "" + db_container_path = "" + db_user = "" + db_pass = "" + db_port = 0 + + if db_engine == "sqlite": + db_name = Prompt.ask("Database Name", default="local") + if not db_name.endswith(".sqlite"): + db_name += ".sqlite" + + compose_path = path / "docker-compose.yml" + if compose_path.exists(): + content = compose_path.read_text() + lines = content.splitlines(keepends=True) + new_lines = [] + in_app_service = False + inserted = False + + for line in lines: + new_lines.append(line) + if not inserted: + if re.search(r"^ app:", line): + in_app_service = True + elif in_app_service and re.search(r"^ volumes:", line): + new_lines.append( + f" - ./{db_name}:/config/{db_name}\n" + ) + in_app_service = False + inserted = True + elif in_app_service and re.search(r"^ [a-zA-Z]", line): + in_app_service = False + + with open(compose_path, "w") as f: + f.writelines(new_lines) + + add_db_to_json( + path, + { + "name": db_name, + "database": f"/config/{db_name}", + "type": "sqlite", + "generated_id": str(uuid.uuid4()), + }, + ) + console.print(f"[success]✔ Added SQLite database ({db_name})[/success]") - elif db_engine == "mongodb": - db_port = get_free_port() - db_name = f"mongo_{secrets.token_hex(4)}" - if db_variant == "with-auth": + elif db_engine == "postgresql": + db_port = get_free_port() db_user = "admin" - db_pass = secrets.token_hex(8) - service_name = f"db-mongo-auth-{secrets.token_hex(2)}" + db_pass = generate_password(16) + db_name = f"pg_{secrets.token_hex(4)}" + service_name = f"db-pg-{secrets.token_hex(2)}" var_prefix = service_name.upper().replace("-", "_") env_vars[f"{var_prefix}_PORT"] = str(db_port) env_vars[f"{var_prefix}_DB"] = db_name env_vars[f"{var_prefix}_USER"] = db_user env_vars[f"{var_prefix}_PASS"] = db_pass snippet = ( - AGENT_MONGODB_AUTH_SNIPPET.replace("${SERVICE_NAME}", service_name) + AGENT_POSTGRES_SNIPPET.replace("${SERVICE_NAME}", service_name) .replace("${PORT}", f"${{{var_prefix}_PORT}}") .replace("${VOL_NAME}", f"{service_name}-data") .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") .replace("${USER}", f"${{{var_prefix}_USER}}") .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") ) - else: - service_name = f"db-mongo-{secrets.token_hex(2)}" + + elif db_engine in ["mysql", "mariadb"]: + db_port = get_free_port() + db_user = "admin" + db_pass = generate_password(16) + db_name = f"mysql_{secrets.token_hex(4)}" + service_name = f"db-mariadb-{secrets.token_hex(2)}" var_prefix = service_name.upper().replace("-", "_") env_vars[f"{var_prefix}_PORT"] = str(db_port) env_vars[f"{var_prefix}_DB"] = db_name + env_vars[f"{var_prefix}_USER"] = db_user + env_vars[f"{var_prefix}_PASS"] = db_pass snippet = ( - AGENT_MONGODB_SNIPPET.replace("${SERVICE_NAME}", service_name) + AGENT_MARIADB_SNIPPET.replace("${SERVICE_NAME}", service_name) .replace("${PORT}", f"${{{var_prefix}_PORT}}") .replace("${VOL_NAME}", f"{service_name}-data") .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + .replace("${USER}", f"${{{var_prefix}_USER}}") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") ) - elif db_engine == "firebird": - db_port = get_free_port() - db_user = "alice" - db_pass = secrets.token_hex(8) - db_name = "mirror.fdb" - service_name = f"db-firebird-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = ( - AGENT_FIREBIRD_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - compose_path = path / "docker-compose.yml" - if compose_path.exists(): - with open(compose_path, "r") as f: - content = f.read() - - if "\nnetworks:" in content: - insert_pos = content.find("\nnetworks:") + 1 - elif content.startswith("networks:"): - insert_pos = 0 - else: - insert_pos = len(content) + elif db_engine == "mongodb": + db_port = get_free_port() + db_name = f"mongo_{secrets.token_hex(4)}" + if db_variant == "with-auth": + db_user = "admin" + db_pass = generate_password(16) + service_name = f"db-mongo-auth-{secrets.token_hex(2)}" + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(db_port) + env_vars[f"{var_prefix}_DB"] = db_name + env_vars[f"{var_prefix}_USER"] = db_user + env_vars[f"{var_prefix}_PASS"] = db_pass + snippet = ( + AGENT_MONGODB_AUTH_SNIPPET.replace( + "${SERVICE_NAME}", service_name + ) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + .replace("${USER}", f"${{{var_prefix}_USER}}") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + ) + else: + service_name = f"db-mongo-{secrets.token_hex(2)}" + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(db_port) + env_vars[f"{var_prefix}_DB"] = db_name + snippet = ( + AGENT_MONGODB_SNIPPET.replace("${SERVICE_NAME}", service_name) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + ) + + elif db_engine == "firebird": + db_port = get_free_port() + db_user = "alice" + db_pass = generate_password(16) + db_root_pass = generate_password(16) + db_name = "mirror.fdb" + db_container_path = f"/var/lib/firebird/data/{db_name}" + service_name = f"db-firebird-{secrets.token_hex(2)}" + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(db_port) + env_vars[f"{var_prefix}_DB"] = db_name + env_vars[f"{var_prefix}_USER"] = db_user + env_vars[f"{var_prefix}_PASS"] = db_pass + env_vars[f"{var_prefix}_ROOT_PASS"] = db_root_pass + snippet = ( + AGENT_FIREBIRD_SNIPPET.replace("${SERVICE_NAME}", service_name) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + .replace("${USER}", f"${{{var_prefix}_USER}}") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + .replace("${ROOT_PASSWORD}", f"${{{var_prefix}_ROOT_PASS}}") + ) - new_content = content[:insert_pos] + snippet + "\n" + content[insert_pos:] + elif db_engine == "redis": + db_port = get_free_port() + db_name = f"redis_{secrets.token_hex(4)}" + if db_variant == "with-auth": + db_pass = generate_password(16) + service_name = f"db-redis-auth-{secrets.token_hex(2)}" + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(db_port) + env_vars[f"{var_prefix}_PASS"] = db_pass + snippet = ( + AGENT_REDIS_AUTH_SNIPPET.replace( + "${SERVICE_NAME}", service_name + ) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + ) + else: + service_name = f"db-redis-{secrets.token_hex(2)}" + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(db_port) + snippet = ( + AGENT_REDIS_SNIPPET.replace("${SERVICE_NAME}", service_name) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + ) + + elif db_engine == "valkey": + db_port = get_free_port() + db_name = f"valkey_{secrets.token_hex(4)}" + if db_variant == "with-auth": + db_pass = generate_password(16) + service_name = f"db-valkey-auth-{secrets.token_hex(2)}" + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(db_port) + env_vars[f"{var_prefix}_PASS"] = db_pass + snippet = ( + AGENT_VALKEY_AUTH_SNIPPET.replace( + "${SERVICE_NAME}", service_name + ) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + ) + else: + service_name = f"db-valkey-{secrets.token_hex(2)}" + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(db_port) + snippet = ( + AGENT_VALKEY_SNIPPET.replace("${SERVICE_NAME}", service_name) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + ) + + elif db_engine == "mssql": + db_port = get_free_port() + db_pass = generate_password(16) + db_name = "master" + service_name = f"db-mssql-{secrets.token_hex(2)}" + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(db_port) + env_vars[f"{var_prefix}_PASS"] = db_pass + snippet = ( + AGENT_MSSQL_SNIPPET.replace("${SERVICE_NAME}", service_name) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + ) - vol_snippet = f" {service_name}-data:\n" + compose_path = path / "docker-compose.yml" + if compose_path.exists(): + content = compose_path.read_text() - vol_pos = -1 - if "\nvolumes:" in new_content: - vol_pos = new_content.find("\nvolumes:") + 1 - elif new_content.startswith("volumes:"): - vol_pos = 0 + vol_match = re.search(r"^volumes:", content, re.MULTILINE) + net_match = re.search(r"^networks:", content, re.MULTILINE) - if vol_pos != -1: - end_of_volumes = new_content.find("\nnetworks:", vol_pos) - if end_of_volumes == -1: - end_of_volumes = len(new_content) + if vol_match: + insert_pos = vol_match.start() + elif net_match: + insert_pos = net_match.start() + else: + insert_pos = len(content) + + content = content[:insert_pos] + snippet + "\n" + content[insert_pos:] + + vol_match = re.search(r"^volumes:", content, re.MULTILINE) + net_match = re.search(r"^networks:", content, re.MULTILINE) + vol_entry = f" {service_name}-data:\n" + + if vol_match: + if net_match and net_match.start() > vol_match.start(): + content = ( + content[: net_match.start()] + + vol_entry + + content[net_match.start() :] + ) + else: + if not content.endswith("\n"): + content += "\n" + content += vol_entry else: - end_of_volumes += 1 + if not content.endswith("\n"): + content += "\n" + content += "\nvolumes:\n" + vol_entry - new_content = ( - new_content[:end_of_volumes] - + vol_snippet - + new_content[end_of_volumes:] - ) - else: - new_content += f"\nvolumes:\n{vol_snippet}" - - with open(compose_path, "w") as f: - f.write(new_content) - - write_env_file(path, env_vars) - add_db_to_json( - path, - { - "name": db_name, - "database": db_name, - "type": db_engine, - "username": db_user, - "password": db_pass, - "port": db_port, - "host": "localhost", - "generated_id": str(uuid.uuid4()), - }, - ) + with open(compose_path, "w") as f: + f.write(content) + + if db_engine != "sqlite": + write_env_file(path, env_vars) + add_db_to_json( + path, + { + "name": "mirror.fdb" if db_engine == "firebird" else db_name, + "database": db_container_path + if db_engine == "firebird" + else ("0" if db_engine in ["redis", "valkey"] else db_name), + "type": db_engine, + "username": "sa" if db_engine == "mssql" else db_user, + "password": db_pass, + "port": 5432 + if db_engine == "postgresql" + else ( + 3050 + if db_engine == "firebird" + else ( + 3306 + if db_engine in ["mysql", "mariadb"] + else ( + 1433 + if db_engine == "mssql" + else ( + 6379 if db_engine in ["redis", "valkey"] else 27017 + ) + ) + ) + ), + "host": service_name, + "generated_id": str(uuid.uuid4()), + }, + ) + break console.print("[success]✔ Database added to configuration.[/success]") console.print( diff --git a/core/utils.py b/core/utils.py index bde0210..0cf5e05 100644 --- a/core/utils.py +++ b/core/utils.py @@ -3,17 +3,28 @@ import json import platform import random +import secrets import shutil import socket +import string import subprocess import time from pathlib import Path import typer +from questionary import Style from rich.align import Align from rich.console import Console, Theme from rich.prompt import Confirm +questionary_style = Style( + [ + ("pointer", "fg:#ff8800 bold"), + ("highlighted", "fg:black bg:#ff8800 bold"), + ("selected", "fg:#ff8800 bold"), + ] +) + custom_theme = Theme( { "info": "dim cyan", @@ -54,6 +65,31 @@ ] +def generate_password(length: int = 16) -> str: + + if length < 8: + length = 8 + + lower = string.ascii_lowercase + upper = string.ascii_uppercase + digits = string.digits + symbols = "!@#$%^&*()-_=+[]{}|;:,.<>?" + + password = [ + secrets.choice(lower), + secrets.choice(upper), + secrets.choice(digits), + secrets.choice(symbols), + ] + + all_chars = lower + upper + digits + symbols + password += [secrets.choice(all_chars) for _ in range(length - 4)] + + secrets.SystemRandom().shuffle(password) + + return "".join(password) + + def get_random_hint(): return f"[hint]{random.choice(HINTS)}[/hint]" diff --git a/main.py b/main.py index c054b64..6f34425 100644 --- a/main.py +++ b/main.py @@ -6,7 +6,10 @@ from core.updater import check_for_updates, update_cli from core.utils import console, current_version -app = typer.Typer(no_args_is_help=True, add_completion=False) +app = typer.Typer( + no_args_is_help=True, + add_completion=False, +) def version_callback(value: bool): @@ -19,7 +22,7 @@ def version_callback(value: bool): @app.callback() def main( ctx: typer.Context, - version: Optional[bool] = typer.Option( + _: Optional[bool] = typer.Option( None, "--version", help="Show the version and exit.", @@ -27,25 +30,56 @@ def main( is_eager=True, ), ): + """ + Portabase CLI to manage agents, dashboards and databases. + """ if ctx.invoked_subcommand != "update": check_for_updates() -@app.command() +@app.command(help="Update the CLI to the latest version.", rich_help_panel="System") def update(): update_cli() -app.command()(agent.agent) -app.command()(dashboard.dashboard) -app.command()(common.start) -app.command()(common.stop) -app.command()(common.restart) -app.command()(common.logs) -app.command()(common.uninstall) +app.command( + help="Create a new Portabase Agent instance.", + rich_help_panel="Creation", + no_args_is_help=True, +)(agent.agent) +app.command( + help="Create a new Portabase Dashboard instance.", + rich_help_panel="Creation", + no_args_is_help=True, +)(dashboard.dashboard) +app.command( + help="Start a Portabase component.", + rich_help_panel="Lifecycle", + no_args_is_help=True, +)(common.start) +app.command( + help="Stop a Portabase component.", + rich_help_panel="Lifecycle", + no_args_is_help=True, +)(common.stop) +app.command( + help="Restart a Portabase component.", + rich_help_panel="Lifecycle", + no_args_is_help=True, +)(common.restart) +app.command( + help="View logs of a Portabase component.", + rich_help_panel="Lifecycle", + no_args_is_help=True, +)(common.logs) +app.command( + help="Uninstall and delete a Portabase component.", + rich_help_panel="Lifecycle", + no_args_is_help=True, +)(common.uninstall) -app.add_typer(db.app, name="db") -app.add_typer(config.app, name="config") +app.add_typer(db.app, name="db", rich_help_panel="Configuration") +app.add_typer(config.app, name="config", rich_help_panel="Configuration") if __name__ == "__main__": app() diff --git a/pyproject.toml b/pyproject.toml index e8d3c26..f50fc08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,4 +10,6 @@ dependencies = [ "pyinstaller>=6.17.0", "requests>=2.32.5", "pyyaml >=6.0.0", + "questionary>=2.1.0", + "pyyaml>=6.0.3" ] diff --git a/templates/compose.py b/templates/compose.py index 83bbfe7..d2bc011 100644 --- a/templates/compose.py +++ b/templates/compose.py @@ -1,10 +1,9 @@ AGENT_POSTGRES_SNIPPET = """ ${SERVICE_NAME}: - container_name: ${PROJECT_NAME}-${SERVICE_NAME} image: postgres:17-alpine + restart: unless-stopped networks: - portabase - - default ports: - "${PORT}:5432" volumes: @@ -13,12 +12,19 @@ - POSTGRES_DB=${DB_NAME} - POSTGRES_USER=${USER} - POSTGRES_PASSWORD=${PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${USER} -d ${DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 """ AGENT_MARIADB_SNIPPET = """ ${SERVICE_NAME}: - container_name: ${PROJECT_NAME}-${SERVICE_NAME} image: mariadb:latest + restart: unless-stopped + networks: + - portabase ports: - "${PORT}:3306" environment: @@ -28,12 +34,19 @@ - MYSQL_RANDOM_ROOT_PASSWORD=yes volumes: - ${VOL_NAME}:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u ${USER} -p${PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 """ AGENT_MONGODB_AUTH_SNIPPET = """ ${SERVICE_NAME}: - container_name: ${PROJECT_NAME}-${SERVICE_NAME} image: mongo:latest + restart: unless-stopped + networks: + - portabase ports: - "${PORT}:27017" environment: @@ -43,28 +56,38 @@ command: mongod --auth volumes: - ${VOL_NAME}:/data/db + healthcheck: + test: ["CMD-SHELL", "mongosh --eval 'db.runCommand({ping:1})' --quiet"] + interval: 10s + timeout: 5s + retries: 5 """ AGENT_MONGODB_SNIPPET = """ ${SERVICE_NAME}: - container_name: ${PROJECT_NAME}-${SERVICE_NAME} image: mongo:latest + restart: unless-stopped + networks: + - portabase ports: - "${PORT}:27017" environment: - MONGO_INITDB_DATABASE=${DB_NAME} volumes: - ${VOL_NAME}:/data/db + healthcheck: + test: ["CMD-SHELL", "mongosh --eval 'db.runCommand({ping:1})' --quiet"] + interval: 10s + timeout: 5s + retries: 5 """ AGENT_FIREBIRD_SNIPPET = """ ${SERVICE_NAME}: - container_name: ${PROJECT_NAME}-${SERVICE_NAME} image: firebirdsql/firebird - restart: always + restart: unless-stopped networks: - portabase - - default ports: - "${PORT}:3050" volumes: @@ -73,9 +96,108 @@ - FIREBIRD_DATABASE=${DB_NAME} - FIREBIRD_USER=${USER} - FIREBIRD_PASSWORD=${PASSWORD} - - FIREBIRD_ROOT_PASSWORD=${PASSWORD} + - FIREBIRD_ROOT_PASSWORD=${ROOT_PASSWORD} - FIREBIRD_DATABASE_DEFAULT_CHARSET=UTF8 + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 3050"] + interval: 10s + timeout: 5s + retries: 5 """ +AGENT_REDIS_SNIPPET = """ + ${SERVICE_NAME}: + image: redis:latest + ports: + - "${PORT}:6379" + volumes: + - ${VOL_NAME}:/data + command: [ "redis-server", "--appendonly", "yes" ] + networks: + - portabase + - default + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 5 +""" + +AGENT_REDIS_AUTH_SNIPPET = """ + ${SERVICE_NAME}: + image: redis:latest + ports: + - "${PORT}:6379" + volumes: + - ${VOL_NAME}:/data + environment: + - REDIS_PASSWORD=${PASSWORD} + command: [ "redis-server", "--requirepass", "${PASSWORD}", "--appendonly", "yes" ] + networks: + - portabase + - default + healthcheck: + test: ["CMD-SHELL", "redis-cli -a ${PASSWORD} ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 5 +""" + +AGENT_VALKEY_SNIPPET = """ + ${SERVICE_NAME}: + image: valkey/valkey:latest + environment: + - ALLOW_EMPTY_PASSWORD=yes + ports: + - "${PORT}:6379" + volumes: + - ${VOL_NAME}:/data + networks: + - portabase + - default + healthcheck: + test: ["CMD-SHELL", "valkey-cli ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 5 +""" + +AGENT_VALKEY_AUTH_SNIPPET = """ + ${SERVICE_NAME}: + image: valkey/valkey:latest + command: --requirepass "${PASSWORD}" + ports: + - "${PORT}:6379" + volumes: + - ${VOL_NAME}:/data + networks: + - portabase + - default + healthcheck: + test: ["CMD-SHELL", "valkey-cli -a ${PASSWORD} ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 5 +""" + +AGENT_MSSQL_SNIPPET = """ + ${SERVICE_NAME}: + image: mcr.microsoft.com/azure-sql-edge:latest + restart: unless-stopped + networks: + - portabase + ports: + - "${PORT}:1433" + environment: + - ACCEPT_EULA=Y + - MSSQL_SA_PASSWORD=${PASSWORD} + volumes: + - ${VOL_NAME}:/var/opt/mssql + healthcheck: + test: ["CMD-SHELL", "cat /proc/net/tcp6 | grep -q '059901' || exit 1"] + interval: 10s + timeout: 5s + retries: 20 +"""