Files
saas_backend/scripts/seed_local_ecosystem.py
T

415 lines
14 KiB
Python

"""
Idempotent localhost-only seeder for SaaS ecosystem applications:
- PIM
- Inventory
- Fulfillment & Logistics
Usage:
python scripts/seed_local_ecosystem.py
"""
import sys
import os
import uuid
import logging
from urllib.parse import urlparse
from pathlib import Path
from typing import Dict, Any, List, Optional
from sqlalchemy.engine import make_url
from sqlalchemy.orm import Session
# Setup path
backend_dir = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(backend_dir))
from app.config.settings import settings
from app.config.database import SessionLocal
from app.models.auth.module_model import Module
from app.models.auth.module_environment_model import ModuleEnvironment
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.subscription_plan_model import SubscriptionPlan
from app.models.auth.plan_module_access_model import PlanModuleAccess
logger = logging.getLogger(__name__)
# Constants for the 3 sellable applications
ECOSYSTEM_MODULES = [
{
"module_id": "pim",
"module_name": "Product Information Management",
"description": "Enterprise Product Information Management (PIM)",
"display_order": 1,
"default_frontend_url": "http://127.0.0.1:5173",
"default_backend_url": "http://127.0.0.1:5002",
"trust_secret_env": "MODULE_TRUST_SECRET_PIM",
"access_codes": [
("pim.view", "PIM", "View Products"),
("pim.manage", "PIM", "Manage Products"),
],
},
{
"module_id": "inventory",
"module_name": "Inventory",
"description": "Warehouse and Inventory Management System",
"display_order": 2,
"default_frontend_url": "http://127.0.0.1:5174",
"default_backend_url": "http://127.0.0.1:12001",
"trust_secret_env": "MODULE_TRUST_SECRET_INVENTORY",
"access_codes": [
("inventory.view", "Inventory", "View Inventory"),
("inventory.manage", "Inventory", "Manage Inventory"),
],
},
{
"module_id": "fulfillment",
"module_name": "Fulfillment & Logistics",
"description": "Fulfillment, Logistics and Dispatch Management",
"display_order": 3,
"default_frontend_url": "http://127.0.0.1:5175",
"default_backend_url": "http://127.0.0.1:8080",
"trust_secret_env": "MODULE_TRUST_SECRET_FULFILLMENT",
"access_codes": [
("fulfillment.view", "Fulfillment", "View Orders & Shipments"),
("fulfillment.manage", "Fulfillment", "Manage Fulfillment"),
],
},
]
TEST_PLAN = {
"name": "Local Ecosystem Test",
"description": "Test subscription plan entitling PIM, Inventory, and Fulfillment",
"price": 0.0,
"duration_days": 365,
"max_users_allowed": 50,
"is_public": True,
"status": "active",
}
def is_local_hostname(hostname: Optional[str]) -> bool:
"""Validate that hostname is strictly a local loopback."""
if not hostname:
return True # in-memory or socket
clean_host = hostname.lower().strip("[]")
return clean_host in ["127.0.0.1", "localhost", "::1"]
def validate_http_url(url_str: str, name: str) -> str:
"""Ensure HTTP URL is valid and strictly targets local loopback in local seeder."""
parsed = urlparse(url_str)
if parsed.scheme not in ["http", "https"]:
raise ValueError(f"Invalid URL scheme for {name}: '{url_str}'")
if not is_local_hostname(parsed.hostname):
raise ValueError(
f"Non-local host in {name} '{url_str}'. Local seeder requires localhost/127.0.0.1/::1."
)
return url_str
def verify_safety_gate(db_url: Optional[str] = None, app_env: Optional[str] = None) -> None:
"""Refuse to run unless on local/testing environment and strictly local database target."""
effective_env = app_env or getattr(settings, "APP_ENV", "local")
effective_url = db_url or getattr(settings, "DATABASE_URL", "")
if effective_env not in ["local", "testing"]:
raise RuntimeError(f"Safety gate failed: APP_ENV '{effective_env}' is not local or testing.")
try:
url_obj = make_url(effective_url)
backend_name = url_obj.get_backend_name()
except Exception as e:
raise RuntimeError(f"Safety gate failed: Malformed DATABASE_URL: {e}")
if backend_name not in ["sqlite", "postgresql", "postgres"]:
raise RuntimeError(f"Safety gate failed: Unsupported database dialect '{backend_name}'.")
if backend_name == "sqlite":
return
host = url_obj.host
if not is_local_hostname(host):
raise RuntimeError(
f"Safety gate failed: Non-local database host '{host}'. Only localhost/127.0.0.1/::1 allowed."
)
db_name = url_obj.database or ""
if db_name not in ["saas_local", "saas_test"] and "test" not in db_name:
raise RuntimeError(
f"Safety gate failed: Database name '{db_name}' is not saas_local or an isolated test DB."
)
def validate_and_extract_secrets() -> Dict[str, str]:
"""
Validate that all three module trust secrets exist in explicit process environment variables.
Fails immediately without inserting partial rows if any secret is missing.
"""
secrets = {}
missing = []
for mod_info in ECOSYSTEM_MODULES:
env_var = mod_info["trust_secret_env"]
val = os.getenv(env_var)
if not val or not val.strip():
missing.append(env_var)
else:
secrets[mod_info["module_id"]] = val.strip()
if missing:
raise ValueError(
f"Missing required module trust secret environment variables: {', '.join(missing)}. "
f"Each module requires its explicit variable."
)
return secrets
def seed_ecosystem_modules(db: Session) -> List[Module]:
"""Seed or update the 3 top-level sellable modules."""
seeded_modules = []
for mod_info in ECOSYSTEM_MODULES:
mod_code = mod_info["module_id"]
module = db.query(Module).filter(Module.module_id == mod_code).first()
if not module:
module = Module(
id=uuid.uuid4(),
module_id=mod_code,
module_name=mod_info["module_name"],
description=mod_info["description"],
status="active",
display_order=mod_info["display_order"],
)
db.add(module)
db.flush()
else:
module.module_name = mod_info["module_name"]
module.description = mod_info["description"]
module.display_order = mod_info["display_order"]
module.status = "active"
seeded_modules.append(module)
return seeded_modules
def seed_module_environments(
db: Session,
modules: List[Module],
secrets_map: Dict[str, str],
) -> List[ModuleEnvironment]:
"""Seed or update the default local environment for each module with correct SSO routes."""
seeded_envs = []
mod_lookup = {m.module_id: m for m in modules}
for mod_info in ECOSYSTEM_MODULES:
mod_code = mod_info["module_id"]
module = mod_lookup.get(mod_code)
if not module:
raise ValueError(f"Module {mod_code} was not found in seeded modules")
trust_secret = secrets_map.get(mod_code)
if not trust_secret:
raise ValueError(f"Missing validated trust secret for module '{mod_code}'")
raw_frontend = os.getenv(f"MODULE_URL_{mod_code.upper()}", mod_info["default_frontend_url"])
raw_backend = os.getenv(f"MODULE_BACKEND_URL_{mod_code.upper()}", mod_info["default_backend_url"])
frontend_url = validate_http_url(raw_frontend, f"{mod_code} frontend URL")
backend_url = validate_http_url(raw_backend, f"{mod_code} backend URL")
# Check existing environment by unique (module_id, slug)
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
ModuleEnvironment.slug == "local",
).first()
# Check for multiple active default environments for the same module
other_defaults = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
ModuleEnvironment.is_default == True,
ModuleEnvironment.slug != "local",
).all()
if other_defaults:
raise ValueError(
f"Conflict: Module '{mod_code}' has multiple conflicting default environments configured."
)
if not env:
env = ModuleEnvironment(
id=uuid.uuid4(),
module_id=module.id,
slug="local",
frontend_base_url=frontend_url,
sso_entry_path="/sso/callback",
backend_base_url=backend_url,
sso_exchange_endpoint="/sso/exchange",
permission_sync_endpoint="/internal/permissions/sync",
provisioning_endpoint="/internal/tenants/provision",
trust_type="hmac",
trust_credentials={"hmac_secret": trust_secret},
is_default=True,
is_active=True,
)
db.add(env)
db.flush()
else:
# Check ownership integrity
if env.module_id != module.id:
raise ValueError(f"Integrity violation: environment {env.id} ownership mismatch.")
env.frontend_base_url = frontend_url
env.sso_entry_path = "/sso/callback"
env.backend_base_url = backend_url
env.sso_exchange_endpoint = "/sso/exchange"
env.permission_sync_endpoint = "/internal/permissions/sync"
env.provisioning_endpoint = "/internal/tenants/provision"
env.trust_type = "hmac"
env.trust_credentials = {"hmac_secret": trust_secret}
env.is_default = True
env.is_active = True
seeded_envs.append(env)
return seeded_envs
def seed_module_accesses(db: Session, modules: List[Module]) -> List[ModuleAccess]:
"""Seed baseline module accesses for each application."""
seeded_accesses = []
mod_lookup = {m.module_id: m for m in modules}
for mod_info in ECOSYSTEM_MODULES:
mod_code = mod_info["module_id"]
module = mod_lookup.get(mod_code)
if not module:
continue
for code, category, name in mod_info["access_codes"]:
access = db.query(ModuleAccess).filter(
ModuleAccess.module_id == module.id,
ModuleAccess.access_code == code,
).first()
if not access:
access = ModuleAccess(
id=uuid.uuid4(),
module_id=module.id,
access_code=code,
category=category,
name=name,
)
db.add(access)
db.flush()
else:
access.category = category
access.name = name
seeded_accesses.append(access)
return seeded_accesses
def seed_test_subscription_plan(db: Session, module_accesses: List[ModuleAccess]) -> SubscriptionPlan:
"""Seed the test plan and reconcile all fields completely on every run."""
plan = db.query(SubscriptionPlan).filter(
SubscriptionPlan.name == TEST_PLAN["name"]
).first()
if not plan:
plan = SubscriptionPlan(
id=uuid.uuid4(),
name=TEST_PLAN["name"],
description=TEST_PLAN["description"],
price=TEST_PLAN["price"],
duration_days=TEST_PLAN["duration_days"],
max_users_allowed=TEST_PLAN["max_users_allowed"],
is_public=TEST_PLAN["is_public"],
status=TEST_PLAN["status"],
)
db.add(plan)
db.flush()
else:
# Full field reconciliation on reruns
plan.description = TEST_PLAN["description"]
plan.price = TEST_PLAN["price"]
plan.duration_days = TEST_PLAN["duration_days"]
plan.max_users_allowed = TEST_PLAN["max_users_allowed"]
plan.is_public = TEST_PLAN["is_public"]
plan.status = TEST_PLAN["status"]
# Link module accesses to plan via PlanModuleAccess
existing_pmas = db.query(PlanModuleAccess).filter(
PlanModuleAccess.plan_id == plan.id
).all()
existing_access_ids = {pma.module_access_id for pma in existing_pmas}
for ma in module_accesses:
if ma.id not in existing_access_ids:
pma = PlanModuleAccess(
id=uuid.uuid4(),
plan_id=plan.id,
module_access_id=ma.id,
)
db.add(pma)
db.flush()
return plan
def run_seeder(db: Session, db_url: Optional[str] = None) -> Dict[str, Any]:
"""Execute complete ecosystem seeding within a single transaction."""
# If db_url not provided, inspect from session bind engine URL if available
effective_url = db_url
if not effective_url and db.bind:
try:
effective_url = str(db.bind.url)
except Exception:
pass
verify_safety_gate(db_url=effective_url)
# Pre-validate all secrets before any DB mutations
secrets_map = validate_and_extract_secrets()
try:
modules = seed_ecosystem_modules(db)
envs = seed_module_environments(db, modules, secrets_map)
accesses = seed_module_accesses(db, modules)
plan = seed_test_subscription_plan(db, accesses)
db.commit()
return {
"modules_count": len(modules),
"environments_count": len(envs),
"module_accesses_count": len(accesses),
"plan_name": plan.name,
"status": "success",
}
except Exception as e:
db.rollback()
logger.error(f"Ecosystem seeding failed: {e}")
raise
def main():
print("=" * 60)
print("SaaS Local Ecosystem Seeder (PIM, Inventory, Fulfillment)")
print("=" * 60)
db = SessionLocal()
try:
result = run_seeder(db)
print(f"✓ Seed completed successfully!")
print(f" Modules seeded: {result['modules_count']}")
print(f" Environments seeded: {result['environments_count']}")
print(f" Plan seeded: {result['plan_name']}")
print("=" * 60)
finally:
db.close()
if __name__ == "__main__":
main()