415 lines
14 KiB
Python
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()
|