fix(ci): format setup.py, register CredentialProfile model

- Run ruff format on setup.py to fix pre-existing style violations
- Add CredentialProfile import to models/__init__.py so SQLAlchemy
  can resolve the Device.credential_profile relationship in tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-22 18:38:09 -05:00
parent b1ac1cce24
commit e22163c55f
2 changed files with 336 additions and 139 deletions

View File

@@ -22,6 +22,7 @@ from app.models.config_backup import RouterConfigSnapshot, RouterConfigDiff, Rou
from app.models.device_interface import DeviceInterface
from app.models.wireless_link import WirelessLink, LinkState
from app.models.site_alert import SiteAlertRule, SiteAlertEvent
from app.models.credential_profile import CredentialProfile
__all__ = [
"Tenant",
@@ -55,4 +56,5 @@ __all__ = [
"LinkState",
"SiteAlertRule",
"SiteAlertEvent",
"CredentialProfile",
]

451
setup.py
View File

@@ -42,9 +42,12 @@ COMPOSE_BASE = "docker-compose.yml"
COMPOSE_PROD = "docker-compose.prod.yml"
COMPOSE_BUILD_OVERRIDE = "docker-compose.build.yml"
COMPOSE_CMD = [
"docker", "compose",
"-f", COMPOSE_BASE,
"-f", COMPOSE_PROD,
"docker",
"compose",
"-f",
COMPOSE_BASE,
"-f",
COMPOSE_PROD,
]
REQUIRED_PORTS = {
@@ -59,20 +62,40 @@ REQUIRED_PORTS = {
# ── Color helpers ────────────────────────────────────────────────────────────
def _supports_color() -> bool:
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
_COLOR = _supports_color()
def _c(code: str, text: str) -> str:
return f"\033[{code}m{text}\033[0m" if _COLOR else text
def green(t: str) -> str: return _c("32", t)
def yellow(t: str) -> str: return _c("33", t)
def red(t: str) -> str: return _c("31", t)
def cyan(t: str) -> str: return _c("36", t)
def bold(t: str) -> str: return _c("1", t)
def dim(t: str) -> str: return _c("2", t)
def green(t: str) -> str:
return _c("32", t)
def yellow(t: str) -> str:
return _c("33", t)
def red(t: str) -> str:
return _c("31", t)
def cyan(t: str) -> str:
return _c("36", t)
def bold(t: str) -> str:
return _c("1", t)
def dim(t: str) -> str:
return _c("2", t)
def banner(text: str) -> None:
@@ -124,7 +147,9 @@ def _collect_environment() -> dict:
try:
r = subprocess.run(
["docker", "version", "--format", "{{.Server.Version}}"],
capture_output=True, text=True, timeout=5,
capture_output=True,
text=True,
timeout=5,
)
if r.returncode == 0:
env["docker"] = r.stdout.strip()
@@ -134,7 +159,9 @@ def _collect_environment() -> dict:
try:
r = subprocess.run(
["docker", "compose", "version", "--short"],
capture_output=True, text=True, timeout=5,
capture_output=True,
text=True,
timeout=5,
)
if r.returncode == 0:
env["compose"] = r.stdout.strip()
@@ -145,7 +172,9 @@ def _collect_environment() -> dict:
if sys.platform == "darwin":
r = subprocess.run(
["sysctl", "-n", "hw.memsize"],
capture_output=True, text=True, timeout=5,
capture_output=True,
text=True,
timeout=5,
)
if r.returncode == 0:
env["ram_gb"] = round(int(r.stdout.strip()) / (1024**3))
@@ -167,7 +196,10 @@ def _get_app_version() -> tuple[str, str]:
try:
r = subprocess.run(
["git", "describe", "--tags", "--always"],
capture_output=True, text=True, timeout=5, cwd=PROJECT_ROOT,
capture_output=True,
text=True,
timeout=5,
cwd=PROJECT_ROOT,
)
if r.returncode == 0:
version = r.stdout.strip()
@@ -176,7 +208,10 @@ def _get_app_version() -> tuple[str, str]:
try:
r = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True, text=True, timeout=5, cwd=PROJECT_ROOT,
capture_output=True,
text=True,
timeout=5,
cwd=PROJECT_ROOT,
)
if r.returncode == 0:
build_id = r.stdout.strip()
@@ -203,9 +238,15 @@ class SetupTelemetry:
self._environment = _collect_environment()
self._app_version, self._build_id = _get_app_version()
def step(self, step_name: str, result: str, duration_ms: int | None = None,
error_message: str | None = None, error_code: str | None = None,
metrics: dict | None = None) -> None:
def step(
self,
step_name: str,
result: str,
duration_ms: int | None = None,
error_message: str | None = None,
error_code: str | None = None,
metrics: dict | None = None,
) -> None:
"""Emit a single setup step event. No-op if disabled."""
if not self.enabled:
return
@@ -250,8 +291,14 @@ class SetupTelemetry:
# ── Input helpers ────────────────────────────────────────────────────────────
def ask(prompt: str, default: str = "", required: bool = False,
secret: bool = False, validate=None) -> str:
def ask(
prompt: str,
default: str = "",
required: bool = False,
secret: bool = False,
validate=None,
) -> str:
"""Prompt the user for input with optional default, validation, and secret mode."""
suffix = f" [{default}]" if default else ""
full_prompt = f" {prompt}{suffix}: "
@@ -266,7 +313,9 @@ def ask(prompt: str, default: str = "", required: bool = False,
if default:
return default
if required:
raise SystemExit(f"EOF reached and no default for required field: {prompt}")
raise SystemExit(
f"EOF reached and no default for required field: {prompt}"
)
return ""
value = value.strip()
@@ -312,6 +361,7 @@ def mask_secret(value: str) -> str:
# ── Validators ───────────────────────────────────────────────────────────────
def validate_password_strength(value: str) -> str | None:
if len(value) < 12:
return "Password must be at least 12 characters."
@@ -334,6 +384,7 @@ def validate_domain(value: str) -> str | None:
# ── System checks ────────────────────────────────────────────────────────────
def check_python_version() -> bool:
if sys.version_info < (3, 10):
fail(f"Python 3.10+ required, found {sys.version}")
@@ -346,7 +397,9 @@ def check_docker() -> bool:
try:
result = subprocess.run(
["docker", "info"],
capture_output=True, text=True, timeout=10,
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
fail("Docker is not running. Start Docker and try again.")
@@ -362,7 +415,9 @@ def check_docker() -> bool:
try:
result = subprocess.run(
["docker", "compose", "version"],
capture_output=True, text=True, timeout=10,
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
fail("Docker Compose v2 is not available.")
@@ -382,7 +437,9 @@ def check_ram() -> None:
if sys.platform == "darwin":
result = subprocess.run(
["sysctl", "-n", "hw.memsize"],
capture_output=True, text=True, timeout=5,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return
@@ -484,6 +541,7 @@ def preflight(args: argparse.Namespace) -> bool:
# ── Secret generation ────────────────────────────────────────────────────────
def generate_jwt_secret() -> str:
return secrets.token_urlsafe(64)
@@ -502,6 +560,7 @@ def generate_admin_password() -> str:
# ── Wizard sections ─────────────────────────────────────────────────────────
def wizard_database(config: dict, args: argparse.Namespace) -> None:
section("Database")
info("PostgreSQL superuser password — used for migrations and admin operations.")
@@ -579,8 +638,12 @@ def wizard_admin(config: dict, args: argparse.Namespace) -> None:
error = validate_password_strength(password)
while error:
warn(error)
password = ask("Admin password", secret=True, required=True,
validate=validate_password_strength)
password = ask(
"Admin password",
secret=True,
required=True,
validate=validate_password_strength,
)
error = None # ask() already validated
config["admin_password"] = password
config["admin_password_generated"] = False
@@ -626,7 +689,9 @@ def wizard_email(config: dict, args: argparse.Namespace) -> None:
config["smtp_host"] = ask("SMTP host", required=True)
config["smtp_port"] = ask("SMTP port", default="587")
config["smtp_user"] = ask("SMTP username (optional)")
config["smtp_password"] = ask("SMTP password (optional)", secret=True) if config["smtp_user"] else ""
config["smtp_password"] = (
ask("SMTP password (optional)", secret=True) if config["smtp_user"] else ""
)
config["smtp_from"] = ask("From address", required=True, validate=validate_email)
config["smtp_tls"] = ask_yes_no("Use TLS?", default=True)
@@ -642,16 +707,22 @@ def wizard_domain(config: dict, args: argparse.Namespace) -> None:
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("/")
config["domain"] = domain
# Determine protocol — default HTTPS for production, allow HTTP for LAN/dev
if args.non_interactive:
use_https = not getattr(args, 'no_https', False)
use_https = not getattr(args, "no_https", False)
else:
use_https = ask_yes_no("Use HTTPS? (disable for LAN/dev without TLS)", default=True)
use_https = ask_yes_no(
"Use HTTPS? (disable for LAN/dev without TLS)", default=True
)
protocol = "https" if use_https else "http"
config["app_base_url"] = f"{protocol}://{domain}"
@@ -660,7 +731,9 @@ def wizard_domain(config: dict, args: argparse.Namespace) -> None:
ok(f"APP_BASE_URL={protocol}://{domain}")
ok(f"CORS_ORIGINS={protocol}://{domain}")
if not use_https:
warn("Running without HTTPS — cookies will not be Secure. Fine for LAN, not for public internet.")
warn(
"Running without HTTPS — cookies will not be Secure. Fine for LAN, not for public internet."
)
# ── Reverse proxy ───────────────────────────────────────────────────────────
@@ -784,13 +857,16 @@ def _write_system_file(path: pathlib.Path, content: str) -> bool:
# Ensure parent directory exists
subprocess.run(
["sudo", "mkdir", "-p", str(path.parent)],
check=True, timeout=30,
check=True,
timeout=30,
)
# Write via sudo tee
result = subprocess.run(
["sudo", "tee", str(path)],
input=content, text=True,
capture_output=True, timeout=30,
input=content,
text=True,
capture_output=True,
timeout=30,
)
if result.returncode != 0:
fail(f"sudo tee failed: {result.stderr.strip()}")
@@ -814,7 +890,9 @@ def wizard_reverse_proxy(config: dict, args: argparse.Namespace) -> None:
proxy_val = args.proxy or "skip"
if proxy_val == "skip":
config["proxy_configured"] = False
info("Skipped. Example configs are in infrastructure/reverse-proxy-examples/")
info(
"Skipped. Example configs are in infrastructure/reverse-proxy-examples/"
)
return
valid_proxies = list(PROXY_CONFIGS.keys())
if proxy_val not in valid_proxies:
@@ -824,7 +902,9 @@ def wizard_reverse_proxy(config: dict, args: argparse.Namespace) -> None:
else:
if not ask_yes_no("Configure a reverse proxy now?", default=True):
config["proxy_configured"] = False
info("Skipped. Example configs are in infrastructure/reverse-proxy-examples/")
info(
"Skipped. Example configs are in infrastructure/reverse-proxy-examples/"
)
return
# Detect installed proxies
@@ -859,7 +939,9 @@ def wizard_reverse_proxy(config: dict, args: argparse.Namespace) -> None:
idx = int(choice) - 1
if idx == len(choices):
config["proxy_configured"] = False
info("Skipped. Example configs are in infrastructure/reverse-proxy-examples/")
info(
"Skipped. Example configs are in infrastructure/reverse-proxy-examples/"
)
return
if 0 <= idx < len(choices):
break
@@ -917,7 +999,7 @@ def wizard_reverse_proxy(config: dict, args: argparse.Namespace) -> None:
print(f" {dim('...')}")
print()
custom_path = ask(f"Write config to", default=str(out_path))
custom_path = ask("Write config to", default=str(out_path))
out_path = pathlib.Path(custom_path)
if out_path.exists():
@@ -959,7 +1041,9 @@ def wizard_reverse_proxy(config: dict, args: argparse.Namespace) -> None:
info("Traefik watches for file changes — no reload needed.")
def wizard_telemetry(config: dict, telem: SetupTelemetry, args: argparse.Namespace) -> None:
def wizard_telemetry(
config: dict, telem: SetupTelemetry, args: argparse.Namespace
) -> None:
section("Anonymous Diagnostics")
info("TOD can send anonymous setup and runtime diagnostics to help")
info("identify common failures. No personal data, IPs, hostnames,")
@@ -1043,6 +1127,7 @@ def wizard_build_mode(config: dict, args: argparse.Namespace) -> None:
# ── Summary ──────────────────────────────────────────────────────────────────
def show_summary(config: dict, args: argparse.Namespace) -> bool:
banner("Configuration Summary")
@@ -1060,7 +1145,9 @@ def show_summary(config: dict, args: argparse.Namespace) -> bool:
print(f" {bold('Admin Account')}")
print(f" Email = {config['admin_email']}")
print(f" Password = {'(auto-generated)' if config.get('admin_password_generated') else mask_secret(config['admin_password'])}")
print(
f" Password = {'(auto-generated)' if config.get('admin_password_generated') else mask_secret(config['admin_password'])}"
)
print()
print(f" {bold('Email')}")
@@ -1114,6 +1201,7 @@ def show_summary(config: dict, args: argparse.Namespace) -> bool:
# ── File writers ─────────────────────────────────────────────────────────────
def write_env_prod(config: dict) -> None:
"""Write the .env.prod file."""
db = config["postgres_db"]
@@ -1125,12 +1213,12 @@ def write_env_prod(config: dict) -> None:
smtp_block = ""
if config.get("smtp_configured"):
smtp_block = f"""\
SMTP_HOST={config['smtp_host']}
SMTP_PORT={config['smtp_port']}
SMTP_USER={config.get('smtp_user', '')}
SMTP_PASSWORD={config.get('smtp_password', '')}
SMTP_USE_TLS={'true' if config.get('smtp_tls') else 'false'}
SMTP_FROM_ADDRESS={config['smtp_from']}"""
SMTP_HOST={config["smtp_host"]}
SMTP_PORT={config["smtp_port"]}
SMTP_USER={config.get("smtp_user", "")}
SMTP_PASSWORD={config.get("smtp_password", "")}
SMTP_USE_TLS={"true" if config.get("smtp_tls") else "false"}
SMTP_FROM_ADDRESS={config["smtp_from"]}"""
else:
smtp_block = """\
# Email not configured — re-run setup.py to add SMTP
@@ -1157,8 +1245,8 @@ APP_USER_DATABASE_URL=postgresql+asyncpg://app_user:{app_pw}@postgres:5432/{db}
POLLER_DATABASE_URL=postgres://poller_user:{poll_pw}@postgres:5432/{db}?sslmode=disable
# --- Security ---
JWT_SECRET_KEY={config['jwt_secret']}
CREDENTIAL_ENCRYPTION_KEY={config['encryption_key']}
JWT_SECRET_KEY={config["jwt_secret"]}
CREDENTIAL_ENCRYPTION_KEY={config["encryption_key"]}
# --- OpenBao (KMS) ---
OPENBAO_ADDR=http://openbao:8200
@@ -1166,22 +1254,22 @@ OPENBAO_TOKEN=PLACEHOLDER_RUN_SETUP
BAO_UNSEAL_KEY=PLACEHOLDER_RUN_SETUP
# --- Admin Bootstrap ---
FIRST_ADMIN_EMAIL={config['admin_email']}
FIRST_ADMIN_PASSWORD={config['admin_password']}
FIRST_ADMIN_EMAIL={config["admin_email"]}
FIRST_ADMIN_PASSWORD={config["admin_password"]}
# --- Email ---
{smtp_block}
# --- Web ---
APP_BASE_URL={config['app_base_url']}
CORS_ORIGINS={config['cors_origins']}
APP_BASE_URL={config["app_base_url"]}
CORS_ORIGINS={config["cors_origins"]}
# --- Application ---
ENVIRONMENT=production
LOG_LEVEL=info
DEBUG=false
APP_NAME=TOD - The Other Dude
TOD_VERSION={config.get('tod_version', 'latest')}
TOD_VERSION={config.get("tod_version", "latest")}
# --- Storage ---
GIT_STORE_PATH=/data/git-store
@@ -1212,7 +1300,7 @@ CONFIG_BACKUP_MAX_CONCURRENT=10
# --- Telemetry ---
# Opt-in anonymous diagnostics. Set to false to disable.
TELEMETRY_ENABLED={'true' if config.get('telemetry_enabled') else 'false'}
TELEMETRY_ENABLED={"true" if config.get("telemetry_enabled") else "false"}
TELEMETRY_COLLECTOR_URL={_TELEMETRY_COLLECTOR}
"""
@@ -1306,7 +1394,8 @@ def prepare_data_dirs() -> None:
try:
subprocess.run(
["sudo", "chown", "-R", f"{APPUSER_UID}:{APPUSER_UID}", str(path)],
check=True, timeout=10,
check=True,
timeout=10,
)
ok(f"{d} (owned by appuser via sudo)")
except Exception:
@@ -1322,14 +1411,17 @@ def prepare_data_dirs() -> None:
try:
subprocess.run(
["sudo", "chmod", "-R", "777", str(path)],
check=True, timeout=10,
check=True,
timeout=10,
)
ok(f"{d} (world-writable via sudo)")
except Exception:
warn(f"{d} — could not set permissions, VPN config sync may fail")
# Create/update WireGuard forwarding init script (always overwrite for isolation rules)
fwd_script = PROJECT_ROOT / "docker-data/wireguard/custom-cont-init.d/10-forwarding.sh"
fwd_script = (
PROJECT_ROOT / "docker-data/wireguard/custom-cont-init.d/10-forwarding.sh"
)
fwd_script.write_text("""\
#!/bin/sh
# Enable forwarding between Docker network and WireGuard tunnel
@@ -1360,8 +1452,10 @@ echo "WireGuard forwarding and tenant isolation rules applied"
# ── Docker operations ────────────────────────────────────────────────────────
def run_compose(*args, check: bool = True, capture: bool = False,
timeout: int = 600) -> subprocess.CompletedProcess:
def run_compose(
*args, check: bool = True, capture: bool = False, timeout: int = 600
) -> subprocess.CompletedProcess:
"""Run a docker compose command with the prod overlay."""
cmd = COMPOSE_CMD + ["--env-file", str(ENV_PROD)] + list(args)
return subprocess.run(
@@ -1393,8 +1487,16 @@ def bootstrap_openbao(config: dict) -> bool:
healthy = False
while time.time() < deadline:
result = subprocess.run(
["docker", "inspect", "--format", "{{.State.Health.Status}}", "tod_openbao"],
capture_output=True, text=True, timeout=10,
[
"docker",
"inspect",
"--format",
"{{.State.Health.Status}}",
"tod_openbao",
],
capture_output=True,
text=True,
timeout=10,
)
status = result.stdout.strip()
if status == "healthy":
@@ -1426,10 +1528,12 @@ def bootstrap_openbao(config: dict) -> bool:
# Update .env.prod
env_content = ENV_PROD.read_text()
env_content = env_content.replace("OPENBAO_TOKEN=PLACEHOLDER_RUN_SETUP",
f"OPENBAO_TOKEN={root_token}")
env_content = env_content.replace("BAO_UNSEAL_KEY=PLACEHOLDER_RUN_SETUP",
f"BAO_UNSEAL_KEY={unseal_key}")
env_content = env_content.replace(
"OPENBAO_TOKEN=PLACEHOLDER_RUN_SETUP", f"OPENBAO_TOKEN={root_token}"
)
env_content = env_content.replace(
"BAO_UNSEAL_KEY=PLACEHOLDER_RUN_SETUP", f"BAO_UNSEAL_KEY={unseal_key}"
)
ENV_PROD.write_text(env_content)
ENV_PROD.chmod(0o600)
@@ -1441,7 +1545,9 @@ def bootstrap_openbao(config: dict) -> bool:
# OpenBao was already initialized — check if .env.prod has real values
env_content = ENV_PROD.read_text()
if "PLACEHOLDER_RUN_SETUP" in env_content:
warn("Could not find credentials in logs (OpenBao may already be initialized).")
warn(
"Could not find credentials in logs (OpenBao may already be initialized)."
)
warn("Check 'docker compose logs openbao' and update .env.prod manually.")
return False
else:
@@ -1467,8 +1573,10 @@ def pull_images() -> bool:
print()
warn("Check your internet connection and that the image exists.")
warn("To retry:")
info(f" docker compose -f {COMPOSE_BASE} -f {COMPOSE_PROD} "
f"--env-file .env.prod pull {service}")
info(
f" docker compose -f {COMPOSE_BASE} -f {COMPOSE_PROD} "
f"--env-file .env.prod pull {service}"
)
return False
except subprocess.TimeoutExpired:
fail(f"Pull of {service} timed out (10 min)")
@@ -1496,8 +1604,10 @@ def build_images() -> bool:
fail(f"Failed to build {service}")
print()
warn("To retry this build:")
info(f" docker compose -f {COMPOSE_BASE} -f {COMPOSE_PROD} "
f"-f {COMPOSE_BUILD_OVERRIDE} build {service}")
info(
f" docker compose -f {COMPOSE_BASE} -f {COMPOSE_PROD} "
f"-f {COMPOSE_BUILD_OVERRIDE} build {service}"
)
return False
except subprocess.TimeoutExpired:
fail(f"Build of {service} timed out (15 min)")
@@ -1548,10 +1658,16 @@ def health_check(config: dict) -> None:
for container, label in list(pending.items()):
try:
result = subprocess.run(
["docker", "inspect", "--format",
[
"docker",
"inspect",
"--format",
"{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}",
container],
capture_output=True, text=True, timeout=5,
container,
],
capture_output=True,
text=True,
timeout=5,
)
status = result.stdout.strip()
if status in ("healthy", "running"):
@@ -1583,7 +1699,7 @@ def health_check(config: dict) -> None:
if config.get("admin_password_generated"):
print(f" Password: {bold(config['admin_password'])}")
else:
print(f" Password: (the password you entered)")
print(" Password: (the password you entered)")
print()
info("Change the admin password after your first login.")
else:
@@ -1593,6 +1709,7 @@ def health_check(config: dict) -> None:
# ── Main ─────────────────────────────────────────────────────────────────────
def _timed(telem: SetupTelemetry, step_name: str, func, *args, **kwargs):
"""Run func, emit a telemetry event with timing. Returns func's result."""
t0 = time.monotonic()
@@ -1604,8 +1721,11 @@ def _timed(telem: SetupTelemetry, step_name: str, func, *args, **kwargs):
except Exception as e:
duration_ms = int((time.monotonic() - t0) * 1000)
telem.step(
step_name, "failure", duration_ms=duration_ms,
error_message=str(e), error_code=type(e).__name__,
step_name,
"failure",
duration_ms=duration_ms,
error_message=str(e),
error_code=type(e).__name__,
)
raise
@@ -1617,44 +1737,93 @@ def _build_parser() -> argparse.ArgumentParser:
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--non-interactive", action="store_true",
"--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("--no-https", action="store_true", default=False,
help="Use HTTP instead of HTTPS (for LAN/dev without TLS)")
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("--build-mode", type=str, default=None,
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(
"--no-https",
action="store_true",
default=False,
help="Use HTTP instead of HTTPS (for LAN/dev without TLS)",
)
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(
"--build-mode",
type=str,
default=None,
choices=["prebuilt", "source"],
help="Image source: prebuilt (pull from GHCR) or source (compile locally)")
parser.add_argument("--yes", "-y", action="store_true", default=False,
help="Auto-confirm summary (don't prompt for confirmation)")
help="Image source: prebuilt (pull from GHCR) or source (compile locally)",
)
parser.add_argument(
"--yes",
"-y",
action="store_true",
default=False,
help="Auto-confirm summary (don't prompt for confirmation)",
)
return parser
@@ -1670,15 +1839,20 @@ def main() -> int:
def handle_sigint(sig, frame):
nonlocal env_written
telem.step("setup_total", "failure",
telem.step(
"setup_total",
"failure",
duration_ms=int((time.monotonic() - setup_start) * 1000),
error_message="User cancelled (SIGINT)")
error_message="User cancelled (SIGINT)",
)
print()
if not env_written:
info("Aborted before writing .env.prod — no files changed.")
else:
warn(f".env.prod was already written to {ENV_PROD}")
info("OpenBao tokens may still be placeholders if bootstrap didn't complete.")
info(
"OpenBao tokens may still be placeholders if bootstrap didn't complete."
)
sys.exit(1)
signal.signal(signal.SIGINT, handle_sigint)
@@ -1706,16 +1880,20 @@ def main() -> int:
wizard_reverse_proxy(config, args)
telem.step("wizard", "success")
except Exception as e:
telem.step("wizard", "failure",
error_message=str(e), error_code=type(e).__name__)
telem.step(
"wizard", "failure", error_message=str(e), error_code=type(e).__name__
)
raise
# Summary
if not show_summary(config, args):
info("Setup cancelled.")
telem.step("setup_total", "failure",
telem.step(
"setup_total",
"failure",
duration_ms=int((time.monotonic() - setup_start) * 1000),
error_message="User cancelled at summary")
error_message="User cancelled at summary",
)
return 1
# Phase 3: Write files and prepare directories
@@ -1727,8 +1905,9 @@ def main() -> int:
prepare_data_dirs()
telem.step("write_config", "success")
except Exception as e:
telem.step("write_config", "failure",
error_message=str(e), error_code=type(e).__name__)
telem.step(
"write_config", "failure", error_message=str(e), error_code=type(e).__name__
)
raise
# Phase 4: OpenBao
@@ -1738,13 +1917,23 @@ def main() -> int:
if bao_ok:
telem.step("openbao_bootstrap", "success", duration_ms=duration_ms)
else:
telem.step("openbao_bootstrap", "failure", duration_ms=duration_ms,
error_message="OpenBao did not become healthy or credentials not found")
if not ask_yes_no("Continue without OpenBao credentials? (stack will need manual fix)", default=False):
telem.step(
"openbao_bootstrap",
"failure",
duration_ms=duration_ms,
error_message="OpenBao did not become healthy or credentials not found",
)
if not ask_yes_no(
"Continue without OpenBao credentials? (stack will need manual fix)",
default=False,
):
warn("Fix OpenBao credentials in .env.prod and re-run setup.py.")
telem.step("setup_total", "failure",
telem.step(
"setup_total",
"failure",
duration_ms=int((time.monotonic() - setup_start) * 1000),
error_message="Aborted after OpenBao failure")
error_message="Aborted after OpenBao failure",
)
return 1
# Phase 5: Build or Pull
@@ -1764,9 +1953,12 @@ def main() -> int:
duration_ms = int((time.monotonic() - t0) * 1000)
telem.step(step_name, "failure", duration_ms=duration_ms)
warn(retry_hint)
telem.step("setup_total", "failure",
telem.step(
"setup_total",
"failure",
duration_ms=int((time.monotonic() - setup_start) * 1000),
error_message=fail_msg)
error_message=fail_msg,
)
return 1
duration_ms = int((time.monotonic() - t0) * 1000)
telem.step(step_name, "success", duration_ms=duration_ms)
@@ -1776,9 +1968,12 @@ def main() -> int:
if not start_stack():
duration_ms = int((time.monotonic() - t0) * 1000)
telem.step("start_stack", "failure", duration_ms=duration_ms)
telem.step("setup_total", "failure",
telem.step(
"setup_total",
"failure",
duration_ms=int((time.monotonic() - setup_start) * 1000),
error_message="Stack failed to start")
error_message="Stack failed to start",
)
return 1
duration_ms = int((time.monotonic() - t0) * 1000)
telem.step("start_stack", "success", duration_ms=duration_ms)