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.device_interface import DeviceInterface
from app.models.wireless_link import WirelessLink, LinkState from app.models.wireless_link import WirelessLink, LinkState
from app.models.site_alert import SiteAlertRule, SiteAlertEvent from app.models.site_alert import SiteAlertRule, SiteAlertEvent
from app.models.credential_profile import CredentialProfile
__all__ = [ __all__ = [
"Tenant", "Tenant",
@@ -55,4 +56,5 @@ __all__ = [
"LinkState", "LinkState",
"SiteAlertRule", "SiteAlertRule",
"SiteAlertEvent", "SiteAlertEvent",
"CredentialProfile",
] ]

473
setup.py
View File

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