Files
blackroad-operating-system/scripts/railway/validate_env_template.py
2025-11-19 20:29:00 -06:00

248 lines
7.1 KiB
Python

"""Validate Railway env template and deployment config."""
from __future__ import annotations
import argparse
import ast
import json
from pathlib import Path
from typing import Dict, List, Set, Tuple
import tomllib
REPO_ROOT = Path(__file__).resolve().parents[2]
BACKEND_DIR = REPO_ROOT / "backend"
CONFIG_PATH = BACKEND_DIR / "app" / "config.py"
ENV_TEMPLATE_PATH = BACKEND_DIR / ".env.example"
RAILWAY_TOML_PATH = REPO_ROOT / "railway.toml"
RAILWAY_JSON_PATH = REPO_ROOT / "railway.json"
# Keys that live outside app.config.Settings but should exist for automation
EXTRA_REQUIRED_KEYS: Set[str] = {
"RAILWAY_TOKEN",
"RAILWAY_PROJECT_ID",
"RAILWAY_ENVIRONMENT_ID",
"RAILWAY_DOMAIN",
"SLACK_WEBHOOK_URL",
"DISCORD_WEBHOOK_URL",
"DIGITAL_OCEAN_API_KEY",
"CLOUDFLARE_API_TOKEN",
"GITHUB_TOKEN",
"HUGGINGFACE_TOKEN",
"VERCEL_TOKEN",
"VERCEL_TEAM_ID",
"STRIPE_SECRET_KEY",
"STRIPE_PUBLISHABLE_KEY",
"TWILIO_ACCOUNT_SID",
"TWILIO_AUTH_TOKEN",
"TWILIO_PHONE_NUMBER",
"SLACK_BOT_TOKEN",
"DISCORD_BOT_TOKEN",
"SENTRY_DSN",
"SENTRY_AUTH_TOKEN",
"SENTRY_ORG",
"ROADCHAIN_RPC_URL",
"ROADCOIN_POOL_URL",
"ROADCOIN_WALLET_ADDRESS",
"MQTT_BROKER_URL",
"MQTT_USERNAME",
"MQTT_PASSWORD",
"DEVICE_HEARTBEAT_TIMEOUT_SECONDS",
"ANTHROPIC_API_KEY",
"POSTGRES_URL",
"JWT_SECRET",
"SESSION_SECRET",
"NEXTAUTH_SECRET",
"NODE_ENV",
"PYTHON_ENV",
# Cloudflare details used by infrastructure automation and DNS routes
"CLOUDFLARE_ACCOUNT_ID",
"CLOUDFLARE_ZONE_ID",
"CLOUDFLARE_EMAIL",
}
SENSITIVE_KEYS: Set[str] = {
"DATABASE_URL",
"DATABASE_ASYNC_URL",
"REDIS_URL",
"SECRET_KEY",
"WALLET_MASTER_KEY",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"SMTP_PASSWORD",
"OPENAI_API_KEY",
"RAILWAY_TOKEN",
"RAILWAY_PROJECT_ID",
"RAILWAY_ENVIRONMENT_ID",
"RAILWAY_DOMAIN",
"SLACK_WEBHOOK_URL",
"DISCORD_WEBHOOK_URL",
"DIGITAL_OCEAN_API_KEY",
"CLOUDFLARE_API_TOKEN",
"GITHUB_TOKEN",
"HUGGINGFACE_TOKEN",
"VERCEL_TOKEN",
"STRIPE_SECRET_KEY",
"STRIPE_PUBLISHABLE_KEY",
"TWILIO_ACCOUNT_SID",
"TWILIO_AUTH_TOKEN",
"TWILIO_PHONE_NUMBER",
"SLACK_BOT_TOKEN",
"DISCORD_BOT_TOKEN",
"SENTRY_DSN",
"SENTRY_AUTH_TOKEN",
"ROADCHAIN_RPC_URL",
"ROADCOIN_POOL_URL",
"ROADCOIN_WALLET_ADDRESS",
"MQTT_BROKER_URL",
"MQTT_PASSWORD",
"ANTHROPIC_API_KEY",
"POSTGRES_URL",
"JWT_SECRET",
"SESSION_SECRET",
"NEXTAUTH_SECRET",
}
PLACEHOLDER_MARKERS: Tuple[str, ...] = (
"changeme",
"your_",
"your-",
"placeholder",
"example",
"dummy",
"xxxx",
"xxx",
"yyy",
"zzz",
"0000",
)
def parse_env_template(path: Path) -> Dict[str, str]:
env: Dict[str, str] = {}
for raw_line in path.read_text().splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip()
return env
def extract_settings_fields(config_path: Path) -> List[str]:
tree = ast.parse(config_path.read_text())
fields: Set[str] = set()
class SettingsVisitor(ast.NodeVisitor):
def visit_ClassDef(self, node: ast.ClassDef) -> None:
if node.name != "Settings":
return
for stmt in node.body:
if isinstance(stmt, ast.ClassDef):
continue
if isinstance(stmt, ast.Assign):
for target in stmt.targets:
if isinstance(target, ast.Name):
fields.add(target.id)
elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
fields.add(stmt.target.id)
SettingsVisitor().visit(tree)
return sorted(fields)
def is_placeholder(value: str) -> bool:
lower_value = value.lower()
return any(marker in lower_value for marker in PLACEHOLDER_MARKERS)
def validate_env_template() -> None:
if not ENV_TEMPLATE_PATH.exists():
raise SystemExit(".env.example template is missing")
env_values = parse_env_template(ENV_TEMPLATE_PATH)
settings_fields = extract_settings_fields(CONFIG_PATH)
required_keys = set(settings_fields) | EXTRA_REQUIRED_KEYS
missing = sorted(key for key in required_keys if key not in env_values)
if missing:
raise SystemExit(
"Missing keys in .env.example: " + ", ".join(missing)
)
unexpected = sorted(
key for key in env_values.keys() if key not in required_keys
)
if unexpected:
print("⚠️ Found extra keys in .env.example:", ", ".join(unexpected))
insecure = sorted(
key
for key in SENSITIVE_KEYS
if key in env_values and not is_placeholder(env_values[key])
)
if insecure:
raise SystemExit(
"Sensitive keys must use placeholders: " + ", ".join(insecure)
)
print("✅ .env.example matches app.config.Settings")
def validate_railway_configs() -> None:
toml_data = tomllib.loads(RAILWAY_TOML_PATH.read_text())
json_data = json.loads(RAILWAY_JSON_PATH.read_text()) if RAILWAY_JSON_PATH.exists() else {}
build = toml_data.get("build", {})
if build.get("builder") != "DOCKERFILE":
raise SystemExit("railway.toml must use the Dockerfile builder")
if build.get("dockerfilePath") != "backend/Dockerfile":
raise SystemExit("railway.toml dockerfilePath must be backend/Dockerfile")
deploy = toml_data.get("deploy", {})
start_command = deploy.get("startCommand", "")
if "$PORT" not in start_command:
raise SystemExit("Railway start command must forward the $PORT value")
services = toml_data.get("services", [])
env_names = {
env_entry.get("name")
for service in services
if isinstance(service, dict)
for env_entry in service.get("env", [])
if isinstance(env_entry, dict)
}
for required in ("ENVIRONMENT", "DEBUG"):
if required not in env_names:
raise SystemExit(f"Railway services must set {required}")
json_build = json_data.get("build", {})
if json_build.get("builder") and json_build.get("builder") != "DOCKERFILE":
raise SystemExit("railway.json builder must remain DOCKERFILE")
json_path = json_build.get("dockerfilePath")
if json_path and json_path != "backend/Dockerfile":
raise SystemExit("railway.json dockerfilePath must match backend/Dockerfile")
print("✅ Railway deployment descriptors look consistent")
def main() -> None:
parser = argparse.ArgumentParser(description="Validate Railway secret automation")
parser.add_argument("--skip-env", action="store_true", help="Skip env template validation")
parser.add_argument("--skip-config", action="store_true", help="Skip Railway config validation")
args = parser.parse_args()
if not args.skip_env:
validate_env_template()
if not args.skip_config:
validate_railway_configs()
print("\nAll Railway automation checks passed ✅")
if __name__ == "__main__":
main()