feat(setup): add CLI switches for non-interactive setup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
181
setup.py
181
setup.py
@@ -5,9 +5,15 @@ Interactive setup script that configures .env.prod, bootstraps OpenBao,
|
|||||||
builds Docker images, starts the stack, and verifies service health.
|
builds Docker images, starts the stack, and verifies service health.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 setup.py
|
python3 setup.py # Interactive mode
|
||||||
|
python3 setup.py --non-interactive \\
|
||||||
|
--postgres-password 'MyP@ss!' \\
|
||||||
|
--domain tod.example.com \\
|
||||||
|
--admin-email admin@example.com \\
|
||||||
|
--no-telemetry --yes # Non-interactive mode
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
import datetime
|
import datetime
|
||||||
import getpass
|
import getpass
|
||||||
@@ -250,10 +256,17 @@ def ask(prompt: str, default: str = "", required: bool = False,
|
|||||||
full_prompt = f" {prompt}{suffix}: "
|
full_prompt = f" {prompt}{suffix}: "
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
try:
|
||||||
if secret:
|
if secret:
|
||||||
value = getpass.getpass(full_prompt)
|
value = getpass.getpass(full_prompt)
|
||||||
else:
|
else:
|
||||||
value = input(full_prompt)
|
value = input(full_prompt)
|
||||||
|
except EOFError:
|
||||||
|
if default:
|
||||||
|
return default
|
||||||
|
if required:
|
||||||
|
raise SystemExit(f"EOF reached and no default for required field: {prompt}")
|
||||||
|
return ""
|
||||||
|
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
if not value and default:
|
if not value and default:
|
||||||
@@ -276,7 +289,10 @@ def ask_yes_no(prompt: str, default: bool = False) -> bool:
|
|||||||
"""Ask a yes/no question."""
|
"""Ask a yes/no question."""
|
||||||
hint = "Y/n" if default else "y/N"
|
hint = "Y/n" if default else "y/N"
|
||||||
while True:
|
while True:
|
||||||
|
try:
|
||||||
answer = input(f" {prompt} [{hint}]: ").strip().lower()
|
answer = input(f" {prompt} [{hint}]: ").strip().lower()
|
||||||
|
except EOFError:
|
||||||
|
return default
|
||||||
if not answer:
|
if not answer:
|
||||||
return default
|
return default
|
||||||
if answer in ("y", "yes"):
|
if answer in ("y", "yes"):
|
||||||
@@ -402,11 +418,18 @@ def check_ports() -> None:
|
|||||||
info(f"Could not check port {port} ({service})")
|
info(f"Could not check port {port} ({service})")
|
||||||
|
|
||||||
|
|
||||||
def check_existing_env() -> str:
|
def check_existing_env(args: argparse.Namespace) -> str:
|
||||||
"""Check for existing .env.prod. Returns 'overwrite', 'backup', or 'abort'."""
|
"""Check for existing .env.prod. Returns 'overwrite', 'backup', or 'abort'."""
|
||||||
if not ENV_PROD.exists():
|
if not ENV_PROD.exists():
|
||||||
return "overwrite"
|
return "overwrite"
|
||||||
|
|
||||||
|
if args.non_interactive:
|
||||||
|
ts = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||||
|
backup = ENV_PROD.with_name(f".env.prod.backup.{ts}")
|
||||||
|
shutil.copy2(ENV_PROD, backup)
|
||||||
|
ok(f"Backed up existing .env.prod to {backup.name}")
|
||||||
|
return "overwrite"
|
||||||
|
|
||||||
print()
|
print()
|
||||||
warn(f"Existing .env.prod found at {ENV_PROD}")
|
warn(f"Existing .env.prod found at {ENV_PROD}")
|
||||||
print()
|
print()
|
||||||
@@ -432,7 +455,7 @@ def check_existing_env() -> str:
|
|||||||
warn("Please enter 1, 2, or 3.")
|
warn("Please enter 1, 2, or 3.")
|
||||||
|
|
||||||
|
|
||||||
def preflight() -> bool:
|
def preflight(args: argparse.Namespace) -> bool:
|
||||||
"""Run all pre-flight checks. Returns True if OK to proceed."""
|
"""Run all pre-flight checks. Returns True if OK to proceed."""
|
||||||
banner("TOD Production Setup")
|
banner("TOD Production Setup")
|
||||||
print(" This wizard will configure your production environment,")
|
print(" This wizard will configure your production environment,")
|
||||||
@@ -449,7 +472,7 @@ def preflight() -> bool:
|
|||||||
check_ram()
|
check_ram()
|
||||||
check_ports()
|
check_ports()
|
||||||
|
|
||||||
action = check_existing_env()
|
action = check_existing_env(args)
|
||||||
if action == "abort":
|
if action == "abort":
|
||||||
print()
|
print()
|
||||||
info("Setup aborted.")
|
info("Setup aborted.")
|
||||||
@@ -478,12 +501,18 @@ def generate_admin_password() -> str:
|
|||||||
|
|
||||||
# ── Wizard sections ─────────────────────────────────────────────────────────
|
# ── Wizard sections ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def wizard_database(config: dict) -> None:
|
def wizard_database(config: dict, args: argparse.Namespace) -> None:
|
||||||
section("Database")
|
section("Database")
|
||||||
info("PostgreSQL superuser password — used for migrations and admin operations.")
|
info("PostgreSQL superuser password — used for migrations and admin operations.")
|
||||||
info("The app and poller service passwords will be auto-generated.")
|
info("The app and poller service passwords will be auto-generated.")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
if args.non_interactive:
|
||||||
|
if not args.postgres_password:
|
||||||
|
fail("--postgres-password is required in non-interactive mode.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
config["postgres_password"] = args.postgres_password
|
||||||
|
else:
|
||||||
config["postgres_password"] = ask(
|
config["postgres_password"] = ask(
|
||||||
"PostgreSQL superuser password",
|
"PostgreSQL superuser password",
|
||||||
required=True,
|
required=True,
|
||||||
@@ -516,11 +545,14 @@ def wizard_security(config: dict) -> None:
|
|||||||
info(f"CREDENTIAL_ENCRYPTION_KEY={mask_secret(config['encryption_key'])}")
|
info(f"CREDENTIAL_ENCRYPTION_KEY={mask_secret(config['encryption_key'])}")
|
||||||
|
|
||||||
|
|
||||||
def wizard_admin(config: dict) -> None:
|
def wizard_admin(config: dict, args: argparse.Namespace) -> None:
|
||||||
section("Admin Account")
|
section("Admin Account")
|
||||||
info("The first admin account is created on initial startup.")
|
info("The first admin account is created on initial startup.")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
if args.non_interactive:
|
||||||
|
config["admin_email"] = args.admin_email or "admin@the-other-dude.dev"
|
||||||
|
else:
|
||||||
config["admin_email"] = ask(
|
config["admin_email"] = ask(
|
||||||
"Admin email",
|
"Admin email",
|
||||||
default="admin@the-other-dude.dev",
|
default="admin@the-other-dude.dev",
|
||||||
@@ -528,6 +560,16 @@ def wizard_admin(config: dict) -> None:
|
|||||||
validate=validate_email,
|
validate=validate_email,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if args.non_interactive:
|
||||||
|
if args.admin_password:
|
||||||
|
config["admin_password"] = args.admin_password
|
||||||
|
config["admin_password_generated"] = False
|
||||||
|
else:
|
||||||
|
config["admin_password"] = generate_admin_password()
|
||||||
|
config["admin_password_generated"] = True
|
||||||
|
ok(f"Generated password: {bold(config['admin_password'])}")
|
||||||
|
warn("Save this now — it will not be shown again after setup.")
|
||||||
|
else:
|
||||||
print()
|
print()
|
||||||
info("Enter a password or press Enter to auto-generate one.")
|
info("Enter a password or press Enter to auto-generate one.")
|
||||||
password = ask("Admin password", secret=True)
|
password = ask("Admin password", secret=True)
|
||||||
@@ -548,11 +590,32 @@ def wizard_admin(config: dict) -> None:
|
|||||||
warn("Save this now — it will not be shown again after setup.")
|
warn("Save this now — it will not be shown again after setup.")
|
||||||
|
|
||||||
|
|
||||||
def wizard_email(config: dict) -> None:
|
def wizard_email(config: dict, args: argparse.Namespace) -> None:
|
||||||
section("Email (SMTP)")
|
section("Email (SMTP)")
|
||||||
info("Email is used for password reset links.")
|
info("Email is used for password reset links.")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
if args.non_interactive:
|
||||||
|
if not args.smtp_host:
|
||||||
|
config["smtp_configured"] = False
|
||||||
|
info("Skipped — no --smtp-host provided.")
|
||||||
|
return
|
||||||
|
config["smtp_configured"] = True
|
||||||
|
config["smtp_host"] = args.smtp_host
|
||||||
|
config["smtp_port"] = args.smtp_port or "587"
|
||||||
|
config["smtp_user"] = args.smtp_user or ""
|
||||||
|
config["smtp_password"] = args.smtp_password or ""
|
||||||
|
config["smtp_from"] = args.smtp_from or ""
|
||||||
|
if not config["smtp_from"]:
|
||||||
|
fail("--smtp-from is required when --smtp-host is provided.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
# Determine TLS setting: --no-smtp-tls wins if set, otherwise default True
|
||||||
|
if args.no_smtp_tls:
|
||||||
|
config["smtp_tls"] = False
|
||||||
|
else:
|
||||||
|
config["smtp_tls"] = True
|
||||||
|
return
|
||||||
|
|
||||||
if not ask_yes_no("Configure SMTP now?", default=False):
|
if not ask_yes_no("Configure SMTP now?", default=False):
|
||||||
config["smtp_configured"] = False
|
config["smtp_configured"] = False
|
||||||
info("Skipped — you can re-run setup.py later to configure email.")
|
info("Skipped — you can re-run setup.py later to configure email.")
|
||||||
@@ -567,12 +630,19 @@ def wizard_email(config: dict) -> None:
|
|||||||
config["smtp_tls"] = ask_yes_no("Use TLS?", default=True)
|
config["smtp_tls"] = ask_yes_no("Use TLS?", default=True)
|
||||||
|
|
||||||
|
|
||||||
def wizard_domain(config: dict) -> None:
|
def wizard_domain(config: dict, args: argparse.Namespace) -> None:
|
||||||
section("Web / Domain")
|
section("Web / Domain")
|
||||||
info("Your production domain, used for CORS and email links.")
|
info("Your production domain, used for CORS and email links.")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
if args.non_interactive:
|
||||||
|
if not args.domain:
|
||||||
|
fail("--domain is required in non-interactive mode.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
raw = args.domain
|
||||||
|
else:
|
||||||
raw = ask("Production domain (e.g. tod.example.com)", required=True, validate=validate_domain)
|
raw = ask("Production domain (e.g. tod.example.com)", required=True, validate=validate_domain)
|
||||||
|
|
||||||
domain = re.sub(r"^https?://", "", raw).rstrip("/")
|
domain = re.sub(r"^https?://", "", raw).rstrip("/")
|
||||||
config["domain"] = domain
|
config["domain"] = domain
|
||||||
config["app_base_url"] = f"https://{domain}"
|
config["app_base_url"] = f"https://{domain}"
|
||||||
@@ -723,12 +793,24 @@ def _write_system_file(path: pathlib.Path, content: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def wizard_reverse_proxy(config: dict) -> None:
|
def wizard_reverse_proxy(config: dict, args: argparse.Namespace) -> None:
|
||||||
section("Reverse Proxy")
|
section("Reverse Proxy")
|
||||||
info("TOD needs a reverse proxy for HTTPS termination.")
|
info("TOD needs a reverse proxy for HTTPS termination.")
|
||||||
info("Example configs are included for Caddy, nginx, Apache, HAProxy, and Traefik.")
|
info("Example configs are included for Caddy, nginx, Apache, HAProxy, and Traefik.")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
if args.non_interactive:
|
||||||
|
proxy_val = args.proxy or "skip"
|
||||||
|
if proxy_val == "skip":
|
||||||
|
config["proxy_configured"] = False
|
||||||
|
info("Skipped. Example configs are in infrastructure/reverse-proxy-examples/")
|
||||||
|
return
|
||||||
|
valid_proxies = list(PROXY_CONFIGS.keys())
|
||||||
|
if proxy_val not in valid_proxies:
|
||||||
|
fail(f"--proxy must be one of: {', '.join(valid_proxies)}, skip")
|
||||||
|
raise SystemExit(1)
|
||||||
|
selected = proxy_val
|
||||||
|
else:
|
||||||
if not ask_yes_no("Configure a reverse proxy now?", default=True):
|
if not ask_yes_no("Configure a reverse proxy now?", default=True):
|
||||||
config["proxy_configured"] = False
|
config["proxy_configured"] = False
|
||||||
info("Skipped. Example configs are in infrastructure/reverse-proxy-examples/")
|
info("Skipped. Example configs are in infrastructure/reverse-proxy-examples/")
|
||||||
@@ -866,7 +948,7 @@ def wizard_reverse_proxy(config: dict) -> None:
|
|||||||
info("Traefik watches for file changes — no reload needed.")
|
info("Traefik watches for file changes — no reload needed.")
|
||||||
|
|
||||||
|
|
||||||
def wizard_telemetry(config: dict, telem: SetupTelemetry) -> None:
|
def wizard_telemetry(config: dict, telem: SetupTelemetry, args: argparse.Namespace) -> None:
|
||||||
section("Anonymous Diagnostics")
|
section("Anonymous Diagnostics")
|
||||||
info("TOD can send anonymous setup and runtime diagnostics to help")
|
info("TOD can send anonymous setup and runtime diagnostics to help")
|
||||||
info("identify common failures. No personal data, IPs, hostnames,")
|
info("identify common failures. No personal data, IPs, hostnames,")
|
||||||
@@ -878,6 +960,16 @@ def wizard_telemetry(config: dict, telem: SetupTelemetry) -> None:
|
|||||||
info("in .env.prod.")
|
info("in .env.prod.")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
if args.non_interactive:
|
||||||
|
if args.telemetry:
|
||||||
|
config["telemetry_enabled"] = True
|
||||||
|
telem.enable()
|
||||||
|
ok("Diagnostics enabled — thank you!")
|
||||||
|
else:
|
||||||
|
config["telemetry_enabled"] = False
|
||||||
|
info("No diagnostics will be sent.")
|
||||||
|
return
|
||||||
|
|
||||||
if ask_yes_no("Send anonymous diagnostics?", default=False):
|
if ask_yes_no("Send anonymous diagnostics?", default=False):
|
||||||
config["telemetry_enabled"] = True
|
config["telemetry_enabled"] = True
|
||||||
telem.enable()
|
telem.enable()
|
||||||
@@ -889,7 +981,7 @@ def wizard_telemetry(config: dict, telem: SetupTelemetry) -> None:
|
|||||||
|
|
||||||
# ── Summary ──────────────────────────────────────────────────────────────────
|
# ── Summary ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def show_summary(config: dict) -> bool:
|
def show_summary(config: dict, args: argparse.Namespace) -> bool:
|
||||||
banner("Configuration Summary")
|
banner("Configuration Summary")
|
||||||
|
|
||||||
print(f" {bold('Database')}")
|
print(f" {bold('Database')}")
|
||||||
@@ -943,6 +1035,10 @@ def show_summary(config: dict) -> bool:
|
|||||||
print(f" {dim('(will be captured automatically during bootstrap)')}")
|
print(f" {dim('(will be captured automatically during bootstrap)')}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
if args.yes:
|
||||||
|
ok("Auto-confirmed (--yes)")
|
||||||
|
return True
|
||||||
|
|
||||||
return ask_yes_no("Write .env.prod with these settings?", default=True)
|
return ask_yes_no("Write .env.prod with these settings?", default=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -1412,7 +1508,54 @@ def _timed(telem: SetupTelemetry, step_name: str, func, *args, **kwargs):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""Build the CLI argument parser."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="TOD Production Setup Wizard",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--non-interactive", action="store_true",
|
||||||
|
help="Skip all prompts, use defaults + provided flags",
|
||||||
|
)
|
||||||
|
parser.add_argument("--postgres-password", type=str, default=None,
|
||||||
|
help="PostgreSQL superuser password")
|
||||||
|
parser.add_argument("--admin-email", type=str, default=None,
|
||||||
|
help="Admin email (default: admin@the-other-dude.dev)")
|
||||||
|
parser.add_argument("--admin-password", type=str, default=None,
|
||||||
|
help="Admin password (auto-generated if not provided)")
|
||||||
|
parser.add_argument("--domain", type=str, default=None,
|
||||||
|
help="Production domain (e.g. tod.example.com)")
|
||||||
|
parser.add_argument("--smtp-host", type=str, default=None,
|
||||||
|
help="SMTP host (skip email config if not provided)")
|
||||||
|
parser.add_argument("--smtp-port", type=str, default=None,
|
||||||
|
help="SMTP port (default: 587)")
|
||||||
|
parser.add_argument("--smtp-user", type=str, default=None,
|
||||||
|
help="SMTP username")
|
||||||
|
parser.add_argument("--smtp-password", type=str, default=None,
|
||||||
|
help="SMTP password")
|
||||||
|
parser.add_argument("--smtp-from", type=str, default=None,
|
||||||
|
help="SMTP from address")
|
||||||
|
parser.add_argument("--smtp-tls", action="store_true", default=False,
|
||||||
|
help="Use TLS for SMTP (default: true in non-interactive)")
|
||||||
|
parser.add_argument("--no-smtp-tls", action="store_true", default=False,
|
||||||
|
help="Disable TLS for SMTP")
|
||||||
|
parser.add_argument("--proxy", type=str, default=None,
|
||||||
|
help="Reverse proxy type: caddy, nginx, apache, haproxy, traefik, skip")
|
||||||
|
parser.add_argument("--telemetry", action="store_true", default=False,
|
||||||
|
help="Enable anonymous diagnostics")
|
||||||
|
parser.add_argument("--no-telemetry", action="store_true", default=False,
|
||||||
|
help="Disable anonymous diagnostics")
|
||||||
|
parser.add_argument("--yes", "-y", action="store_true", default=False,
|
||||||
|
help="Auto-confirm summary (don't prompt for confirmation)")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
# Parse CLI arguments
|
||||||
|
parser = _build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Graceful Ctrl+C
|
# Graceful Ctrl+C
|
||||||
env_written = False
|
env_written = False
|
||||||
telem = SetupTelemetry()
|
telem = SetupTelemetry()
|
||||||
@@ -1436,23 +1579,23 @@ def main() -> int:
|
|||||||
os.chdir(PROJECT_ROOT)
|
os.chdir(PROJECT_ROOT)
|
||||||
|
|
||||||
# Phase 1: Pre-flight
|
# Phase 1: Pre-flight
|
||||||
if not preflight():
|
if not preflight(args):
|
||||||
telem.step("preflight", "failure")
|
telem.step("preflight", "failure")
|
||||||
return 1
|
return 1
|
||||||
telem.step("preflight", "success")
|
telem.step("preflight", "success")
|
||||||
|
|
||||||
# Telemetry opt-in (right after preflight, before wizard)
|
# Telemetry opt-in (right after preflight, before wizard)
|
||||||
config: dict = {}
|
config: dict = {}
|
||||||
wizard_telemetry(config, telem)
|
wizard_telemetry(config, telem, args)
|
||||||
|
|
||||||
# Phase 2: Wizard
|
# Phase 2: Wizard
|
||||||
try:
|
try:
|
||||||
wizard_database(config)
|
wizard_database(config, args)
|
||||||
wizard_security(config)
|
wizard_security(config)
|
||||||
wizard_admin(config)
|
wizard_admin(config, args)
|
||||||
wizard_email(config)
|
wizard_email(config, args)
|
||||||
wizard_domain(config)
|
wizard_domain(config, args)
|
||||||
wizard_reverse_proxy(config)
|
wizard_reverse_proxy(config, args)
|
||||||
telem.step("wizard", "success")
|
telem.step("wizard", "success")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
telem.step("wizard", "failure",
|
telem.step("wizard", "failure",
|
||||||
@@ -1460,7 +1603,7 @@ def main() -> int:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
if not show_summary(config):
|
if not show_summary(config, args):
|
||||||
info("Setup cancelled.")
|
info("Setup cancelled.")
|
||||||
telem.step("setup_total", "failure",
|
telem.step("setup_total", "failure",
|
||||||
duration_ms=int((time.monotonic() - setup_start) * 1000),
|
duration_ms=int((time.monotonic() - setup_start) * 1000),
|
||||||
|
|||||||
Reference in New Issue
Block a user