chore: initialize insar management system v2
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from typing import Iterable, Set
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def _check_status(
|
||||
response: httpx.Response,
|
||||
expected: Iterable[int],
|
||||
success_message: str,
|
||||
fail_message: str,
|
||||
failures: list[str],
|
||||
) -> bool:
|
||||
expected_set: Set[int] = set(expected)
|
||||
if response.status_code in expected_set:
|
||||
print(f"[PASS] {success_message} (status={response.status_code})")
|
||||
return True
|
||||
print(f"[FAIL] {fail_message} (status={response.status_code}, body={response.text})")
|
||||
failures.append(fail_message)
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Auth and permission smoke checks for InSAR backend.")
|
||||
parser.add_argument("--base-url", default=os.getenv("AUTH_SMOKE_BASE_URL", "http://127.0.0.1:8000"))
|
||||
parser.add_argument("--admin-user", default=os.getenv("INIT_ADMIN_USERNAME", "admin"))
|
||||
parser.add_argument("--admin-password", default=os.getenv("INIT_ADMIN_PASSWORD", "ChangeMe123!"))
|
||||
parser.add_argument("--viewer-user", default=os.getenv("AUTH_SMOKE_VIEWER_USERNAME", ""))
|
||||
parser.add_argument("--viewer-password", default=os.getenv("AUTH_SMOKE_VIEWER_PASSWORD", ""))
|
||||
args = parser.parse_args()
|
||||
|
||||
failures: list[str] = []
|
||||
timeout = httpx.Timeout(10.0)
|
||||
|
||||
try:
|
||||
with httpx.Client(base_url=args.base_url.rstrip("/"), timeout=timeout) as client:
|
||||
print(f"[*] Target: {args.base_url.rstrip('/')}")
|
||||
|
||||
unauth_me = client.get("/api/auth/me")
|
||||
_check_status(
|
||||
unauth_me,
|
||||
{401},
|
||||
"未登录访问 /api/auth/me 返回 401",
|
||||
"未登录访问 /api/auth/me 未返回 401",
|
||||
failures,
|
||||
)
|
||||
|
||||
login_admin = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": args.admin_user, "password": args.admin_password},
|
||||
)
|
||||
if not _check_status(
|
||||
login_admin,
|
||||
{200},
|
||||
"管理员登录成功",
|
||||
"管理员登录失败",
|
||||
failures,
|
||||
):
|
||||
print("[INFO] 管理员登录失败,后续检查跳过。")
|
||||
return 1
|
||||
|
||||
me_admin = client.get("/api/auth/me")
|
||||
if _check_status(
|
||||
me_admin,
|
||||
{200},
|
||||
"管理员会话可访问 /api/auth/me",
|
||||
"管理员会话无法访问 /api/auth/me",
|
||||
failures,
|
||||
):
|
||||
role = (me_admin.json() or {}).get("role")
|
||||
if role != "admin":
|
||||
print(f"[FAIL] 管理员角色异常,实际 role={role}")
|
||||
failures.append("管理员角色异常")
|
||||
else:
|
||||
print("[PASS] 管理员角色校验通过")
|
||||
|
||||
audit_logs = client.get("/api/auth/audit-logs", params={"limit": 20})
|
||||
if _check_status(
|
||||
audit_logs,
|
||||
{200},
|
||||
"管理员可查询审计日志 /api/auth/audit-logs",
|
||||
"管理员无法查询审计日志 /api/auth/audit-logs",
|
||||
failures,
|
||||
):
|
||||
payload = audit_logs.json()
|
||||
if isinstance(payload, list):
|
||||
print(f"[PASS] 审计日志接口返回列表(条数={len(payload)})")
|
||||
else:
|
||||
print("[FAIL] 审计日志接口返回格式不是列表")
|
||||
failures.append("审计日志接口返回格式异常")
|
||||
|
||||
run_now_admin = client.post("/api/monitor/run-now")
|
||||
_check_status(
|
||||
run_now_admin,
|
||||
{200, 202, 400, 409},
|
||||
"管理员可触发写操作接口(未被鉴权拒绝)",
|
||||
"管理员触发写操作接口异常(可能被鉴权拒绝)",
|
||||
failures,
|
||||
)
|
||||
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
if args.viewer_user and args.viewer_password:
|
||||
login_viewer = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": args.viewer_user, "password": args.viewer_password},
|
||||
)
|
||||
if _check_status(
|
||||
login_viewer,
|
||||
{200},
|
||||
"只读用户登录成功",
|
||||
"只读用户登录失败",
|
||||
failures,
|
||||
):
|
||||
me_viewer = client.get("/api/auth/me")
|
||||
if _check_status(
|
||||
me_viewer,
|
||||
{200},
|
||||
"只读用户会话可访问 /api/auth/me",
|
||||
"只读用户会话无法访问 /api/auth/me",
|
||||
failures,
|
||||
):
|
||||
role = (me_viewer.json() or {}).get("role")
|
||||
if role != "viewer":
|
||||
print(f"[FAIL] 只读用户角色异常,实际 role={role}")
|
||||
failures.append("只读用户角色异常")
|
||||
else:
|
||||
print("[PASS] 只读用户角色校验通过")
|
||||
|
||||
run_now_viewer = client.post("/api/monitor/run-now")
|
||||
_check_status(
|
||||
run_now_viewer,
|
||||
{403},
|
||||
"只读用户写操作被拒绝(403)",
|
||||
"只读用户写操作未被拒绝",
|
||||
failures,
|
||||
)
|
||||
client.post("/api/auth/logout")
|
||||
else:
|
||||
print("[INFO] 未提供只读用户凭据,跳过 viewer 鉴权回归。")
|
||||
|
||||
except httpx.RequestError as exc:
|
||||
print(f"[FAIL] 无法连接后端: {exc}")
|
||||
return 1
|
||||
|
||||
if failures:
|
||||
print("\n[RESULT] 鉴权冒烟检查失败。")
|
||||
for item in failures:
|
||||
print(f"- {item}")
|
||||
return 1
|
||||
|
||||
print("\n[RESULT] 鉴权冒烟检查通过。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database Connection Health Check Script.
|
||||
|
||||
Runs before the main system startup to verify PostgreSQL connectivity and authentication.
|
||||
"""
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _project_root() -> str:
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
PROJECT_ROOT = _project_root()
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
from backend.app.config import ensure_project_env_loaded, settings
|
||||
|
||||
|
||||
def check_database_connection() -> bool:
|
||||
ensure_project_env_loaded()
|
||||
print("[*] Loaded deployment config.")
|
||||
|
||||
database_url = settings.DATABASE_URL
|
||||
if not database_url:
|
||||
print("[ERROR] DATABASE_URL not found. Please check .env.")
|
||||
return False
|
||||
|
||||
print("[*] Parsing database URL...")
|
||||
parsed = urlparse(database_url)
|
||||
|
||||
host = parsed.hostname
|
||||
port = parsed.port or 5432
|
||||
user = parsed.username
|
||||
password = parsed.password
|
||||
dbname = parsed.path.strip("/")
|
||||
|
||||
if not host:
|
||||
print("[ERROR] Unable to parse database host.")
|
||||
return False
|
||||
|
||||
print(f"[*] Connecting to database server {host}:{port} ...")
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
try:
|
||||
result = sock.connect_ex((host, port))
|
||||
if result != 0:
|
||||
print(f"[FAIL] Cannot connect to {host}:{port}. Port closed or blocked.")
|
||||
return False
|
||||
print(f"[OK] Port {port} is reachable.")
|
||||
except socket.gaierror:
|
||||
print(f"[FAIL] DNS lookup failed: {host}")
|
||||
return False
|
||||
except Exception as exc:
|
||||
print(f"[FAIL] Connection error: {exc}")
|
||||
return False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
print("[*] Verifying credentials and database access...")
|
||||
conn = None
|
||||
try:
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
dbname=dbname,
|
||||
connect_timeout=5,
|
||||
)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT 1;")
|
||||
cur.fetchone()
|
||||
cur.close()
|
||||
conn.commit()
|
||||
print(f"[OK] Database {dbname} authenticated successfully.")
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
print("[WARN] psycopg2 not installed, trying SQLAlchemy fallback...")
|
||||
try:
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
sync_db_url = f"postgresql://{user}:{password}@{host}:{port}/{dbname}"
|
||||
engine = create_engine(sync_db_url, connect_timeout=5)
|
||||
with engine.connect() as conn_sa:
|
||||
conn_sa.execute(text("SELECT 1"))
|
||||
print(f"[OK] Database {dbname} authenticated (SQLAlchemy fallback).")
|
||||
return True
|
||||
except ImportError:
|
||||
print("[WARN] No Python database drivers available.")
|
||||
print("[INFO] Port check passed; startup will continue.")
|
||||
return True
|
||||
except Exception as exc:
|
||||
print(f"[FAIL] SQLAlchemy validation failed: {exc}")
|
||||
return False
|
||||
finally:
|
||||
if "engine" in locals() and engine:
|
||||
engine.dispose()
|
||||
except Exception as exc:
|
||||
print("\n[FAIL] Database connection failed.")
|
||||
print("=" * 50)
|
||||
print(f"Error type: {type(exc).__name__}")
|
||||
|
||||
diag_message = str(exc)
|
||||
if hasattr(exc, "diag") and exc.diag:
|
||||
diag_message = exc.diag.message_primary or str(exc)
|
||||
|
||||
print(f"Details: {diag_message}")
|
||||
print("=" * 50)
|
||||
|
||||
if "password authentication failed" in diag_message:
|
||||
print("[HINT] Check the password in DATABASE_URL (.env).")
|
||||
elif "does not exist" in diag_message:
|
||||
print(f"[HINT] Database '{dbname}' does not exist.")
|
||||
elif "connection refused" in diag_message:
|
||||
print("[HINT] Connection refused. Verify PostgreSQL allows this IP.")
|
||||
else:
|
||||
print("[HINT] Unknown connection error. Check database configuration.")
|
||||
|
||||
print("\n[CRITICAL] Unable to connect to database. Startup aborted.")
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ok = check_database_connection()
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Check output parameters of each ENVI task."""
|
||||
from envipyengine import Engine
|
||||
|
||||
TASK_NAMES = [
|
||||
"SARsInSARInterferogramGeneration",
|
||||
"SARsInSARFilterAndCoherence",
|
||||
"SARsInSARRemoveResidualPhaseFrequency",
|
||||
"SARsInSARPhaseUnwrapping",
|
||||
"SARsInSARRefinementAndReflattening",
|
||||
"SARsInSARPhaseToDisplacement",
|
||||
]
|
||||
|
||||
engine = Engine("ENVI")
|
||||
for name in TASK_NAMES:
|
||||
task = engine.task(name)
|
||||
params = task.parameters
|
||||
print(f"=== {name} ===")
|
||||
if isinstance(params, (list, tuple)):
|
||||
for p in params:
|
||||
if isinstance(p, dict) and p.get("direction") == "output":
|
||||
print(f" [OUTPUT] {p.get('name')} ({p.get('type')})")
|
||||
print()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
检查 PROJ 数据库配置和版本
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
print("=" * 60)
|
||||
print("PROJ 数据库诊断")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. 检查环境变量
|
||||
print("\n1. 环境变量检查:")
|
||||
proj_lib = os.environ.get("PROJ_LIB")
|
||||
print(f" PROJ_LIB = {proj_lib}")
|
||||
if proj_lib:
|
||||
print(f" 路径存在: {os.path.exists(proj_lib)}")
|
||||
if os.path.exists(proj_lib):
|
||||
proj_db = os.path.join(proj_lib, "proj.db")
|
||||
print(f" proj.db 存在: {os.path.exists(proj_db)}")
|
||||
|
||||
# 2. 检查 GDAL 配置
|
||||
print("\n2. GDAL 配置:")
|
||||
try:
|
||||
from osgeo import gdal
|
||||
gdal_data = gdal.GetConfigOption("GDAL_DATA")
|
||||
print(f" GDAL_DATA = {gdal_data}")
|
||||
|
||||
if gdal_data:
|
||||
proj_lib_auto = os.path.join(os.path.dirname(gdal_data), "proj")
|
||||
print(f" 自动推断 PROJ_LIB = {proj_lib_auto}")
|
||||
print(f" 路径存在: {os.path.exists(proj_lib_auto)}")
|
||||
if os.path.exists(proj_lib_auto):
|
||||
proj_db = os.path.join(proj_lib_auto, "proj.db")
|
||||
print(f" proj.db 存在: {os.path.exists(proj_db)}")
|
||||
except ImportError:
|
||||
print(" GDAL 未安装")
|
||||
|
||||
# 3. 检查 PostgreSQL PROJ
|
||||
print("\n3. PostgreSQL PostGIS PROJ:")
|
||||
pg_proj = r"C:\Program Files\PostgreSQL\17\share\contrib\postgis-3.6\proj\proj.db"
|
||||
print(f" 路径: {pg_proj}")
|
||||
print(f" 存在: {os.path.exists(pg_proj)}")
|
||||
|
||||
# 4. 检查 PROJ 版本
|
||||
print("\n4. PROJ 库版本:")
|
||||
try:
|
||||
import pyproj
|
||||
print(f" pyproj 版本: {pyproj.__version__}")
|
||||
print(f" PROJ 版本: {pyproj.proj_version_str}")
|
||||
print(f" PROJ 数据目录: {pyproj.datadir.get_data_dir()}")
|
||||
except ImportError:
|
||||
print(" pyproj 未安装")
|
||||
|
||||
# 5. 测试坐标转换
|
||||
print("\n5. 坐标转换测试:")
|
||||
try:
|
||||
import pyproj
|
||||
# WGS84 to Web Mercator
|
||||
transformer = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
|
||||
x, y = transformer.transform(116.4, 39.9) # 北京坐标
|
||||
print(f" WGS84 (116.4, 39.9) -> Web Mercator ({x:.2f}, {y:.2f})")
|
||||
print(" ✅ 坐标转换正常")
|
||||
except Exception as e:
|
||||
print(f" ❌ 坐标转换失败: {e}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("诊断完成")
|
||||
print("=" * 60)
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Deployment configuration validation entrypoint.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _project_root() -> str:
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
PROJECT_ROOT = _project_root()
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
|
||||
from backend.app.config import ensure_project_env_loaded, validate_runtime_config
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ensure_project_env_loaded()
|
||||
result = validate_runtime_config()
|
||||
|
||||
print("[*] Validating deployment configuration...")
|
||||
for item in result.get("info", []):
|
||||
print(f"[INFO] {item}")
|
||||
for item in result.get("warnings", []):
|
||||
print(f"[WARN] {item}")
|
||||
for item in result.get("errors", []):
|
||||
print(f"[ERROR] {item}")
|
||||
|
||||
if result.get("ok"):
|
||||
print("[OK] Deployment configuration check passed.")
|
||||
return 0
|
||||
|
||||
print("[FAIL] Deployment configuration check failed.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Read-only smoke check for the catalog-first D-InSAR architecture.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
|
||||
def _project_root() -> str:
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
PROJECT_ROOT = _project_root()
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
|
||||
from backend.app import database
|
||||
from backend.app.config import ensure_project_env_loaded, settings
|
||||
from backend.app.db_maintenance import ensure_database_ready
|
||||
from backend.app.models import AiDiagnosisORM, HazardPointORM, ResultProductORM
|
||||
from backend.app.services.dinsar_read_service import dinsar_read_service
|
||||
from backend.app.services.health_service import get_health_status
|
||||
from backend.app.services.spatial_service import spatial_service
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run a read-only smoke check for D-InSAR catalog/compat integration."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-maintenance",
|
||||
action="store_true",
|
||||
help="Do not run database self-maintenance before the smoke check.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-external",
|
||||
action="store_true",
|
||||
help="Include external checks such as nginx and Ollama.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-refresh",
|
||||
action="store_true",
|
||||
help="Do not force a schema refresh inside health_service.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--compact",
|
||||
action="store_true",
|
||||
help="Print compact JSON instead of pretty JSON.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _print_json(payload: Dict[str, Any], compact: bool) -> None:
|
||||
if compact:
|
||||
print(json.dumps(payload, ensure_ascii=False, default=str))
|
||||
return
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
|
||||
|
||||
|
||||
async def _run_smoke(include_external: bool, refresh: bool) -> Dict[str, Any]:
|
||||
session_factory = database.AsyncSessionLocal
|
||||
if session_factory is None:
|
||||
raise RuntimeError("AsyncSessionLocal is not initialized.")
|
||||
|
||||
health = await get_health_status(
|
||||
include_external=include_external,
|
||||
include_details=True,
|
||||
full=True,
|
||||
refresh=refresh,
|
||||
)
|
||||
|
||||
async with session_factory() as db:
|
||||
catalog_records = await dinsar_read_service.list_catalog_records(db)
|
||||
compat_count = await dinsar_read_service.count_compat_records(db)
|
||||
compat_records = await dinsar_read_service.list_compat_records(db, limit=5, offset=0)
|
||||
|
||||
diagnosis_total = int(
|
||||
(await db.execute(select(func.count(AiDiagnosisORM.id)))).scalar_one() or 0
|
||||
)
|
||||
hazard_id = (
|
||||
await db.execute(
|
||||
select(HazardPointORM.id).order_by(HazardPointORM.id.asc()).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
spatial_count = None
|
||||
if hazard_id is not None:
|
||||
spatial_records = await spatial_service.find_dinsar_results_near_hazard(
|
||||
db,
|
||||
int(hazard_id),
|
||||
)
|
||||
spatial_count = len(spatial_records)
|
||||
|
||||
product_count = int(
|
||||
(
|
||||
await db.execute(
|
||||
select(func.count(ResultProductORM.id)).where(
|
||||
ResultProductORM.catalog_name == "dinsar"
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
or 0
|
||||
)
|
||||
|
||||
return {
|
||||
"health_ok": bool(health.get("ok")),
|
||||
"database": {
|
||||
"ok": bool(health.get("database", {}).get("ok")),
|
||||
"schema_ok": bool(health.get("database", {}).get("schema_ok")),
|
||||
"postgis_ok": bool(health.get("database", {}).get("postgis_ok")),
|
||||
},
|
||||
"worker": {
|
||||
"ok": bool(health.get("worker", {}).get("ok")),
|
||||
"worker_count": int(health.get("worker", {}).get("worker_count") or 0),
|
||||
},
|
||||
"dinsar_result_catalog": health.get("dinsar_result_catalog", {}),
|
||||
"dinsar_bridge": health.get("dinsar_bridge", {}),
|
||||
"source_roots": health.get("source_roots", {}),
|
||||
"reads": {
|
||||
"catalog_records": len(catalog_records),
|
||||
"compat_records": compat_count,
|
||||
"compat_preview_records": len(compat_records),
|
||||
"sample_public_ids": [
|
||||
int(record.compat_row.id)
|
||||
for record in compat_records
|
||||
if getattr(record, "compat_row", None) is not None
|
||||
][:5],
|
||||
},
|
||||
"diagnosis_total": diagnosis_total,
|
||||
"spatial_count_for_first_hazard": spatial_count,
|
||||
"product_count_direct": product_count,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
ensure_project_env_loaded()
|
||||
database_url = settings.DATABASE_URL
|
||||
if not database_url:
|
||||
print("[ERROR] DATABASE_URL not found in .env")
|
||||
return 1
|
||||
|
||||
maintenance = None
|
||||
if not args.skip_maintenance:
|
||||
maintenance = ensure_database_ready(
|
||||
database_url,
|
||||
bootstrap_admin=True,
|
||||
seed_hazard=True,
|
||||
)
|
||||
|
||||
database.init_db()
|
||||
if database.AsyncSessionLocal is None:
|
||||
print("[ERROR] Failed to initialize async database session factory.")
|
||||
return 1
|
||||
|
||||
payload = {
|
||||
"maintenance": maintenance,
|
||||
"smoke": asyncio.run(
|
||||
_run_smoke(
|
||||
include_external=args.include_external,
|
||||
refresh=not args.no_refresh,
|
||||
)
|
||||
),
|
||||
}
|
||||
_print_json(payload, args.compact)
|
||||
|
||||
smoke = payload["smoke"]
|
||||
root_blocked = int(smoke.get("source_roots", {}).get("inaccessible_count") or 0) > 0
|
||||
worker_missing = not bool(smoke.get("worker", {}).get("ok"))
|
||||
return 2 if root_blocked or worker_missing else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Emit launcher-facing runtime configuration as JSON.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _project_root() -> str:
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
PROJECT_ROOT = _project_root()
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
|
||||
from backend.app.config import export_launcher_config
|
||||
|
||||
|
||||
def main() -> int:
|
||||
payload = export_launcher_config()
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Extract parameter names for each ENVI task (input params only)."""
|
||||
import sys
|
||||
|
||||
TASK_NAMES = [
|
||||
"SARsInSARInterferogramGeneration",
|
||||
"SARsInSARFilterAndCoherence",
|
||||
"SARsInSARRemoveResidualPhaseFrequency",
|
||||
"SARsInSARPhaseUnwrapping",
|
||||
"SARsInSARRefinementAndReflattening",
|
||||
"SARsInSARPhaseToDisplacement",
|
||||
]
|
||||
|
||||
def main():
|
||||
from envipyengine import Engine
|
||||
engine = Engine("ENVI")
|
||||
|
||||
for name in TASK_NAMES:
|
||||
task = engine.task(name)
|
||||
params = task.parameters
|
||||
print(f"=== {name} ===")
|
||||
if isinstance(params, (list, tuple)):
|
||||
for p in params:
|
||||
if isinstance(p, dict):
|
||||
direction = p.get("direction", "?")
|
||||
pname = p.get("name", "?")
|
||||
ptype = p.get("type", "?")
|
||||
required = p.get("required", False)
|
||||
req_tag = " [REQUIRED]" if required else ""
|
||||
if direction == "input":
|
||||
print(f" {pname} ({ptype}){req_tag}")
|
||||
print()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,59 @@
|
||||
import hashlib
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
|
||||
def _run_wmic(args: str) -> str:
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["wmic"] + args.split(),
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL,
|
||||
shell=False,
|
||||
)
|
||||
return output.decode("utf-8", errors="ignore").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _run_powershell(cmd: str) -> str:
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["powershell", "-NoProfile", "-Command", cmd],
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL,
|
||||
shell=False,
|
||||
)
|
||||
return output.decode("utf-8", errors="ignore").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _pick_value(text: str) -> str:
|
||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||
if len(lines) <= 1:
|
||||
return ""
|
||||
values = [v for v in lines[1:] if v and v.lower() not in {"serialnumber", "uuid"}]
|
||||
return values[0] if values else ""
|
||||
|
||||
|
||||
def get_fingerprint() -> str:
|
||||
uuid_text = _run_wmic("csproduct get uuid")
|
||||
if not uuid_text:
|
||||
uuid_text = _run_powershell("(Get-CimInstance Win32_ComputerSystemProduct).UUID")
|
||||
|
||||
disk_text = _run_wmic("diskdrive get serialnumber")
|
||||
if not disk_text:
|
||||
disk_text = _run_powershell("(Get-CimInstance Win32_DiskDrive | Select-Object -First 1 -ExpandProperty SerialNumber)")
|
||||
|
||||
uuid_val = _pick_value(uuid_text)
|
||||
disk_val = _pick_value(disk_text)
|
||||
mac_val = f"{uuid.getnode():012x}"
|
||||
if not mac_val or mac_val == "000000000000":
|
||||
mac_val = _run_powershell("(Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | Select-Object -First 1 -ExpandProperty MacAddress)") or ""
|
||||
|
||||
raw = "|".join([uuid_val, disk_val, mac_val])
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(get_fingerprint())
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database initialization and auto-maintenance entrypoint.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _project_root() -> str:
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
PROJECT_ROOT = _project_root()
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
|
||||
from backend.app.config import ensure_project_env_loaded, settings
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ensure_project_env_loaded()
|
||||
|
||||
database_url = settings.DATABASE_URL
|
||||
if not database_url:
|
||||
print("[ERROR] DATABASE_URL not found in .env")
|
||||
return 1
|
||||
|
||||
from backend.app.db_maintenance import ensure_database_ready
|
||||
|
||||
print("[*] Validating and maintaining database schema...")
|
||||
result = ensure_database_ready(database_url, bootstrap_admin=True, seed_hazard=True)
|
||||
|
||||
print(f"[*] Database host: {result.get('host')}")
|
||||
print(f"[*] Database name: {result.get('database')}")
|
||||
if result.get("mismatch_detected"):
|
||||
print("[*] Schema mismatch detected and handled.")
|
||||
for reason in result.get("mismatch_reasons", []):
|
||||
print(f" - {reason}")
|
||||
else:
|
||||
print("[OK] Database schema already matches ORM metadata.")
|
||||
|
||||
added_columns = result.get("added_columns", [])
|
||||
if added_columns:
|
||||
print(f"[OK] Added missing columns ({len(added_columns)}): {added_columns}")
|
||||
|
||||
if result.get("schema_reset"):
|
||||
print("[WARN] Schema was reset because DB_SCHEMA_RESET_ON_MISMATCH and DB_SCHEMA_RESET_CONFIRM are enabled.")
|
||||
|
||||
if result.get("applied_sql_files"):
|
||||
print(f"[OK] Applied SQL maintenance files: {', '.join(result['applied_sql_files'])}")
|
||||
|
||||
admin_status = result.get("admin") or {}
|
||||
if admin_status.get("message"):
|
||||
print(f"[OK] {admin_status['message']}")
|
||||
|
||||
hazard_status = result.get("hazard_seed") or {}
|
||||
if hazard_status.get("message"):
|
||||
prefix = "[OK]" if hazard_status.get("seeded") or hazard_status.get("count") else "[INFO]"
|
||||
print(f"{prefix} {hazard_status['message']}")
|
||||
|
||||
print("\n========================================")
|
||||
print(" Database Initialization Complete! ")
|
||||
print("========================================")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc:
|
||||
print(f"\n[CRITICAL ERROR] {exc}")
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,271 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tkinter as tk
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from tkinter import filedialog, messagebox
|
||||
from typing import Tuple
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
|
||||
APP_TITLE = "InSAR 授权管理器"
|
||||
|
||||
|
||||
def _derive_aes_key(secret: str) -> bytes:
|
||||
return hashlib.sha256(secret.encode("utf-8")).digest()
|
||||
|
||||
|
||||
def _b64encode(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("utf-8")
|
||||
|
||||
|
||||
def _b64decode(data: str) -> bytes:
|
||||
return base64.b64decode(data.encode("utf-8"))
|
||||
|
||||
|
||||
def _load_env(path: str) -> dict:
|
||||
env = {}
|
||||
if not os.path.exists(path):
|
||||
return env
|
||||
with open(path, "r", encoding="utf-8-sig") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, val = line.split("=", 1)
|
||||
env[key.strip()] = val.strip().strip('"').strip("'")
|
||||
return env
|
||||
|
||||
|
||||
def _save_env(path: str, updates: dict) -> None:
|
||||
existing = []
|
||||
if os.path.exists(path):
|
||||
with open(path, "r", encoding="utf-8-sig") as f:
|
||||
existing = f.readlines()
|
||||
|
||||
def _set_line(key: str, value: str) -> bool:
|
||||
for idx, line in enumerate(existing):
|
||||
if line.strip().startswith(key + "="):
|
||||
existing[idx] = f"{key}={value}\n"
|
||||
return True
|
||||
return False
|
||||
|
||||
for key, value in updates.items():
|
||||
if not _set_line(key, value):
|
||||
existing.append(f"{key}={value}\n")
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.writelines(existing)
|
||||
|
||||
|
||||
def _gen_keys() -> Tuple[str, str]:
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
pub = priv.public_key()
|
||||
priv_bytes = priv.private_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PrivateFormat.Raw,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
pub_bytes = pub.public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
return _b64encode(priv_bytes), _b64encode(pub_bytes)
|
||||
|
||||
|
||||
def _load_private_key(path: str) -> Ed25519PrivateKey:
|
||||
raw = _b64decode(open(path, "r", encoding="utf-8").read().strip())
|
||||
return Ed25519PrivateKey.from_private_bytes(raw)
|
||||
|
||||
|
||||
def _public_from_private(priv: Ed25519PrivateKey) -> str:
|
||||
pub = priv.public_key()
|
||||
pub_bytes = pub.public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
return _b64encode(pub_bytes)
|
||||
|
||||
|
||||
def _get_fingerprint() -> str:
|
||||
try:
|
||||
from get_fingerprint import get_fingerprint
|
||||
except Exception:
|
||||
return ""
|
||||
return get_fingerprint()
|
||||
|
||||
|
||||
def _resolve_paths(project_root: str) -> Tuple[str, str]:
|
||||
lic_dir = os.path.join(project_root, "backend", "license")
|
||||
lic_path = os.path.join(lic_dir, "license.lic")
|
||||
return lic_dir, lic_path
|
||||
|
||||
|
||||
def _default_expiry() -> str:
|
||||
return (datetime.now(timezone.utc) + timedelta(days=365)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _parse_expiry(value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
value = _default_expiry()
|
||||
dt = datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
class LicenseManagerApp:
|
||||
def __init__(self, root: tk.Tk):
|
||||
self.root = root
|
||||
self.root.title(APP_TITLE)
|
||||
self.root.geometry("640x420")
|
||||
self.root.resizable(False, False)
|
||||
|
||||
self.env_path = tk.StringVar()
|
||||
self.expiry = tk.StringVar(value=_default_expiry())
|
||||
self.status = tk.StringVar(value="请选择 .env 文件路径")
|
||||
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
frame = tk.Frame(self.root, padx=16, pady=16)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
title = tk.Label(frame, text="离线授权文件生成器", font=("Microsoft YaHei", 14, "bold"))
|
||||
title.pack(anchor="w")
|
||||
|
||||
env_row = tk.Frame(frame)
|
||||
env_row.pack(fill=tk.X, pady=(18, 8))
|
||||
tk.Label(env_row, text=".env 路径:", width=12, anchor="w").pack(side=tk.LEFT)
|
||||
env_entry = tk.Entry(env_row, textvariable=self.env_path, width=60)
|
||||
env_entry.pack(side=tk.LEFT, padx=(0, 8))
|
||||
tk.Button(env_row, text="选择", command=self._pick_env).pack(side=tk.LEFT)
|
||||
|
||||
exp_row = tk.Frame(frame)
|
||||
exp_row.pack(fill=tk.X, pady=8)
|
||||
tk.Label(exp_row, text="到期时间:", width=12, anchor="w").pack(side=tk.LEFT)
|
||||
exp_entry = tk.Entry(exp_row, textvariable=self.expiry, width=30)
|
||||
exp_entry.pack(side=tk.LEFT)
|
||||
tk.Label(exp_row, text="格式:YYYY-MM-DD HH:MM:SS (UTC)", fg="#666").pack(side=tk.LEFT, padx=8)
|
||||
|
||||
btn_row = tk.Frame(frame)
|
||||
btn_row.pack(fill=tk.X, pady=12)
|
||||
tk.Button(btn_row, text="生成/续期授权", width=20, command=self._run).pack(side=tk.LEFT)
|
||||
|
||||
note = tk.Label(
|
||||
frame,
|
||||
text="说明:若检测到公私钥不匹配,将自动重建密钥对(旧授权全部失效)。",
|
||||
fg="#b91c1c",
|
||||
)
|
||||
note.pack(anchor="w", pady=(6, 10))
|
||||
|
||||
status_label = tk.Label(frame, textvariable=self.status, fg="#0f172a", wraplength=580, justify="left")
|
||||
status_label.pack(anchor="w", pady=(12, 0))
|
||||
|
||||
def _pick_env(self):
|
||||
path = filedialog.askopenfilename(title="选择 .env 文件", filetypes=[("Env file", ".env"), ("All files", "*.*")])
|
||||
if path:
|
||||
self.env_path.set(path)
|
||||
self.status.set("准备就绪,可生成授权文件。")
|
||||
|
||||
def _run(self):
|
||||
env_path = self.env_path.get().strip()
|
||||
if not env_path or not os.path.exists(env_path):
|
||||
messagebox.showerror("错误", "请先选择有效的 .env 文件路径。")
|
||||
return
|
||||
|
||||
try:
|
||||
exp_iso = _parse_expiry(self.expiry.get())
|
||||
except Exception:
|
||||
messagebox.showerror("错误", "到期时间格式错误,请使用 YYYY-MM-DD HH:MM:SS (UTC)。")
|
||||
return
|
||||
|
||||
env = _load_env(env_path)
|
||||
secret = env.get("LICENSE_SECRET")
|
||||
if not secret:
|
||||
secret = _b64encode(os.urandom(32))
|
||||
|
||||
project_root = os.path.dirname(env_path)
|
||||
lic_dir, lic_path = _resolve_paths(project_root)
|
||||
private_key_path = os.path.join(project_root, "license_private_key.txt")
|
||||
|
||||
if os.path.exists(private_key_path):
|
||||
try:
|
||||
priv = _load_private_key(private_key_path)
|
||||
pub_from_priv = _public_from_private(priv)
|
||||
pub_env = env.get("LICENSE_PUBLIC_KEY", "")
|
||||
if pub_env and pub_env != pub_from_priv:
|
||||
priv_b64, pub_b64 = _gen_keys()
|
||||
priv = Ed25519PrivateKey.from_private_bytes(_b64decode(priv_b64))
|
||||
pub_from_priv = pub_b64
|
||||
with open(private_key_path, "w", encoding="utf-8") as f:
|
||||
f.write(priv_b64)
|
||||
elif not pub_env:
|
||||
pub_env = pub_from_priv
|
||||
except Exception:
|
||||
priv_b64, pub_b64 = _gen_keys()
|
||||
priv = Ed25519PrivateKey.from_private_bytes(_b64decode(priv_b64))
|
||||
pub_from_priv = pub_b64
|
||||
with open(private_key_path, "w", encoding="utf-8") as f:
|
||||
f.write(priv_b64)
|
||||
else:
|
||||
priv_b64, pub_b64 = _gen_keys()
|
||||
priv = Ed25519PrivateKey.from_private_bytes(_b64decode(priv_b64))
|
||||
pub_from_priv = pub_b64
|
||||
with open(private_key_path, "w", encoding="utf-8") as f:
|
||||
f.write(priv_b64)
|
||||
|
||||
_save_env(env_path, {
|
||||
"LICENSE_SECRET": secret,
|
||||
"LICENSE_PUBLIC_KEY": pub_from_priv,
|
||||
})
|
||||
|
||||
fingerprint = _get_fingerprint()
|
||||
if not fingerprint:
|
||||
messagebox.showerror("错误", "无法获取机器指纹,请检查 get_fingerprint.py 是否可用。")
|
||||
return
|
||||
|
||||
payload = {
|
||||
"product": "insar_management_system_v2",
|
||||
"fingerprint": fingerprint,
|
||||
"expires_at": exp_iso,
|
||||
"issued_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
plaintext = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
aes_key = _derive_aes_key(secret)
|
||||
aesgcm = AESGCM(aes_key)
|
||||
nonce = os.urandom(12)
|
||||
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
|
||||
|
||||
header = b"LIC1"
|
||||
nonce_b64 = _b64encode(nonce).encode("utf-8")
|
||||
ct_b64 = _b64encode(ciphertext).encode("utf-8")
|
||||
data_to_sign = b"|".join([header, nonce_b64, ct_b64])
|
||||
|
||||
signature = priv.sign(data_to_sign)
|
||||
sig_b64 = _b64encode(signature).encode("utf-8")
|
||||
blob = b"|".join([header, sig_b64, nonce_b64, ct_b64])
|
||||
|
||||
os.makedirs(lic_dir, exist_ok=True)
|
||||
with open(lic_path, "wb") as f:
|
||||
f.write(blob)
|
||||
|
||||
self.status.set(
|
||||
"授权文件已生成:\n"
|
||||
f"- license.lic: {lic_path}\n"
|
||||
f"- private key: {private_key_path}\n"
|
||||
f"- expires_at: {exp_iso}\n"
|
||||
"已自动写入 LICENSE_SECRET / LICENSE_PUBLIC_KEY 到 .env"
|
||||
)
|
||||
messagebox.showinfo("完成", "授权文件已生成并写入配置。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
root = tk.Tk()
|
||||
app = LicenseManagerApp(root)
|
||||
root.mainloop()
|
||||
@@ -0,0 +1,41 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
:: =====================================================================
|
||||
:: InSAR Management System - Conda Environment Packing Script
|
||||
:: 该脚本用于将开发环境打包成离线可用的绿色版压缩包
|
||||
:: =====================================================================
|
||||
|
||||
set ENV_NAME=InSAR
|
||||
set OUTPUT_FILE=insar_env_packed.zip
|
||||
|
||||
echo [1/3] 正在检查 conda-pack 是否安装...
|
||||
conda-pack --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [!] 未检测到 conda-pack, 正在尝试安装...
|
||||
pip install conda-pack
|
||||
)
|
||||
|
||||
echo [2/3] 正在清理 Conda 缓存以减小包体积...
|
||||
call conda clean -y --all
|
||||
|
||||
echo [3/3] 正在将环境 %ENV_NAME% 打包为 %OUTPUT_FILE%...
|
||||
echo [*] 这可能需要几分钟时间,请稍候...
|
||||
|
||||
:: 使用 zip 格式在 Windows 上有更好的兼容性
|
||||
if exist %OUTPUT_FILE% del %OUTPUT_FILE%
|
||||
conda pack -n %ENV_NAME% -o %OUTPUT_FILE% --format zip --compress-level 9
|
||||
|
||||
if %errorlevel% equ 0 (
|
||||
echo.
|
||||
echo =====================================================================
|
||||
echo [+] 环境打包成功: %OUTPUT_FILE%
|
||||
echo [+] 你可以将此文件拷贝到内网服务器,解压后即可直接使用。
|
||||
echo [+] 提示: 解压后运行目录下的 scripts\python.exe 即可调用该环境。
|
||||
echo =====================================================================
|
||||
) else (
|
||||
echo.
|
||||
echo [!] 打包失败,请检查环境 %ENV_NAME% 是否存在或是否被占用。
|
||||
)
|
||||
|
||||
pause
|
||||
@@ -0,0 +1,594 @@
|
||||
# InSAR Management System - Startup Script (PowerShell)
|
||||
# Features:
|
||||
# - Process management (stop old instances, start new ones)
|
||||
# - Database health check
|
||||
# - Auto Nginx config update
|
||||
# - Logs output
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# 1. Set project root
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$ProjectRoot = Split-Path -Parent $ScriptDir
|
||||
Set-Location -LiteralPath "$ProjectRoot"
|
||||
|
||||
Write-Host ">>> Starting InSAR Management System V2..." -ForegroundColor Cyan
|
||||
Write-Host ">>> Project Root: $ProjectRoot"
|
||||
|
||||
# 2. Parse .env minimally (only for resolving Python/Conda before handing off to unified config)
|
||||
$EnvPath = Join-Path $ProjectRoot ".env"
|
||||
if (-not (Test-Path -LiteralPath "$EnvPath")) {
|
||||
Write-Error "File .env not found: $EnvPath"
|
||||
return
|
||||
}
|
||||
|
||||
$PythonExe = "python"
|
||||
$CondaExe = ""
|
||||
$CondaEnvName = ""
|
||||
$NginxExe = "C:/nginx-1.29.4/nginx.exe"
|
||||
$ServerHost = ""
|
||||
$ServerPort = 18000
|
||||
|
||||
$envLines = Get-Content -LiteralPath "$EnvPath"
|
||||
foreach ($line in $envLines) {
|
||||
$trimmed = $line.Trim()
|
||||
if ($trimmed.StartsWith("#") -or -not $trimmed.Contains("=")) { continue }
|
||||
|
||||
$parts = $trimmed.Split("=", 2)
|
||||
$key = $parts[0].Trim()
|
||||
$val = $parts[1].Trim().Trim('"').Trim("'")
|
||||
|
||||
if ($key -eq "PYTHON_PATH") { if ($val) { $PythonExe = $val } }
|
||||
if ($key -eq "CONDA_EXE") { if ($val) { $CondaExe = $val } }
|
||||
if ($key -eq "CONDA_ENV_NAME") { if ($val) { $CondaEnvName = $val } }
|
||||
if ($key -eq "NGINX_PATH") { if ($val) { $NginxExe = $val } }
|
||||
if ($key -eq "BACKEND_BIND_HOST") { if ($val) { $ServerHost = $val } }
|
||||
if ($key -eq "PORT") {
|
||||
$parsed = 0
|
||||
if ([int]::TryParse($val, [ref]$parsed)) { $ServerPort = $parsed }
|
||||
}
|
||||
}
|
||||
|
||||
$CheckDbScript = Join-Path $ProjectRoot "scripts\check_db_connection.py"
|
||||
$CheckRuntimeScript = Join-Path $ProjectRoot "scripts\check_runtime_config.py"
|
||||
$ExportLauncherConfigScript = Join-Path $ProjectRoot "scripts\export_launcher_config.py"
|
||||
$InitDbScript = Join-Path $ProjectRoot "scripts\init_db.py"
|
||||
|
||||
function Resolve-ExecutablePath {
|
||||
param([string]$Candidate)
|
||||
|
||||
$trimmed = "$Candidate".Trim().Trim('"').Trim("'")
|
||||
if (-not $trimmed) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath "$trimmed") {
|
||||
$resolved = Resolve-Path -LiteralPath "$trimmed" -ErrorAction SilentlyContinue
|
||||
if ($resolved) {
|
||||
return $resolved.Path
|
||||
}
|
||||
return $trimmed
|
||||
}
|
||||
|
||||
$cmd = Get-Command -Name "$trimmed" -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($cmd -and $cmd.Source) {
|
||||
return $cmd.Source
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-CondaEnvPythonPath {
|
||||
param(
|
||||
[string]$ResolvedCondaExe,
|
||||
[string]$EnvName
|
||||
)
|
||||
|
||||
$trimmedEnvName = "$EnvName".Trim()
|
||||
if (-not $trimmedEnvName) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$anacondaRoot = Split-Path -Parent (Split-Path -Parent "$ResolvedCondaExe")
|
||||
$candidatePaths = @(
|
||||
(Join-Path -Path $anacondaRoot -ChildPath "envs\$trimmedEnvName\python.exe"),
|
||||
(Join-Path -Path $env:USERPROFILE -ChildPath ".conda\envs\$trimmedEnvName\python.exe")
|
||||
)
|
||||
|
||||
foreach ($candidate in $candidatePaths) {
|
||||
if (Test-Path -LiteralPath "$candidate") {
|
||||
$resolved = Resolve-Path -LiteralPath "$candidate" -ErrorAction SilentlyContinue
|
||||
if ($resolved) {
|
||||
return $resolved.Path
|
||||
}
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$envsJson = & "$ResolvedCondaExe" info --envs --json 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $envsJson) {
|
||||
$envsInfo = $envsJson | ConvertFrom-Json
|
||||
foreach ($envPath in ($envsInfo.envs | Where-Object { $_ })) {
|
||||
if ((Split-Path -Leaf "$envPath") -ne $trimmedEnvName) {
|
||||
continue
|
||||
}
|
||||
$pythonPath = Join-Path -Path "$envPath" -ChildPath "python.exe"
|
||||
if (Test-Path -LiteralPath "$pythonPath") {
|
||||
$resolved = Resolve-Path -LiteralPath "$pythonPath" -ErrorAction SilentlyContinue
|
||||
if ($resolved) {
|
||||
return $resolved.Path
|
||||
}
|
||||
return $pythonPath
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# Ignore and continue with fallback.
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Invoke-PythonScript {
|
||||
param([string]$ScriptPath)
|
||||
|
||||
& "$PythonExe" "$ScriptPath"
|
||||
}
|
||||
|
||||
function Invoke-PythonScriptJson {
|
||||
param([string]$ScriptPath)
|
||||
|
||||
$output = & "$PythonExe" "$ScriptPath"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
return $null
|
||||
}
|
||||
if (-not $output) {
|
||||
return $null
|
||||
}
|
||||
return ($output | ConvertFrom-Json)
|
||||
}
|
||||
|
||||
# 3. Stop old instances
|
||||
Write-Host ">>> Stopping existing processes..." -ForegroundColor Yellow
|
||||
|
||||
function Stop-Process-By-Name {
|
||||
param([string]$Name, [string]$ExeName)
|
||||
# Prefer precise cmdline matching; fall back to executable path when CIM command line is unavailable.
|
||||
$NginxConfMatch = Join-Path -Path $ProjectRoot -ChildPath "nginx"
|
||||
$matched = @()
|
||||
$resolvedExe = Resolve-ExecutablePath -Candidate $ExeName
|
||||
|
||||
$candidates = Get-CimInstance Win32_Process -Filter "Name='$Name.exe'" -ErrorAction SilentlyContinue
|
||||
if ($candidates) {
|
||||
foreach ($proc in $candidates) {
|
||||
if ($proc.CommandLine -and $proc.CommandLine -like "*$NginxConfMatch*") {
|
||||
$matched += $proc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($matched.Count -eq 0 -and $resolvedExe) {
|
||||
$processesByPath = Get-Process -Name $Name -ErrorAction SilentlyContinue
|
||||
foreach ($proc in $processesByPath) {
|
||||
$procPath = $null
|
||||
try {
|
||||
$procPath = $proc.Path
|
||||
} catch {
|
||||
$procPath = $null
|
||||
}
|
||||
|
||||
if ($procPath -and ([string]::Equals($procPath, $resolvedExe, [System.StringComparison]::OrdinalIgnoreCase))) {
|
||||
$matched += $proc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($matched.Count -gt 0) {
|
||||
$targetPids = @($matched | ForEach-Object {
|
||||
if ($_.PSObject.Properties['ProcessId']) { $_.ProcessId }
|
||||
elseif ($_.PSObject.Properties['Id']) { $_.Id }
|
||||
} | Where-Object { $_ } | Sort-Object -Unique)
|
||||
|
||||
Write-Host " Stopping $Name (PID: $($targetPids -join ', '))..." -NoNewline
|
||||
foreach ($p in $matched) {
|
||||
$processId = $null
|
||||
if ($p.PSObject.Properties['ProcessId']) { $processId = $p.ProcessId }
|
||||
elseif ($p.PSObject.Properties['Id']) { $processId = $p.Id }
|
||||
if ($processId) {
|
||||
Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
$stillAlive = $false
|
||||
foreach ($targetProcessId in $targetPids) {
|
||||
if (Get-Process -Id $targetProcessId -ErrorAction SilentlyContinue) {
|
||||
$stillAlive = $true
|
||||
}
|
||||
}
|
||||
if (-not $stillAlive) {
|
||||
Write-Host " [Done]" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " [Failed]" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-Backend-By-Cmdline {
|
||||
param([string]$MatchText)
|
||||
$candidates = Get-CimInstance Win32_Process -Filter "Name='python.exe'" -ErrorAction SilentlyContinue
|
||||
foreach ($proc in $candidates) {
|
||||
if ($proc.CommandLine -and $proc.CommandLine -like "*$MatchText*") {
|
||||
try {
|
||||
Write-Host " Stopping python (PID $($proc.ProcessId)) with cmdline match: $MatchText" -NoNewline
|
||||
Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 1
|
||||
$check = Get-Process -Id $proc.ProcessId -ErrorAction SilentlyContinue
|
||||
if (-not $check) {
|
||||
Write-Host " [Done]" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " [Failed]" -ForegroundColor Red
|
||||
}
|
||||
} catch {
|
||||
Write-Host " [Failed]" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ListeningPortOwner {
|
||||
param([int]$Port)
|
||||
|
||||
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($conn) {
|
||||
$procName = "Unknown"
|
||||
$proc = Get-Process -Id $conn.OwningProcess -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
$procName = $proc.ProcessName
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
Port = $Port
|
||||
PID = $conn.OwningProcess
|
||||
ProcessName = $procName
|
||||
LocalAddress = $conn.LocalAddress
|
||||
}
|
||||
}
|
||||
|
||||
$netstatLines = netstat -ano -p tcp 2>$null
|
||||
if ($netstatLines) {
|
||||
foreach ($line in $netstatLines) {
|
||||
$trimmed = "$line".Trim()
|
||||
if (-not $trimmed.StartsWith("TCP")) {
|
||||
continue
|
||||
}
|
||||
if ($trimmed -notmatch "\s+LISTENING\s+") {
|
||||
continue
|
||||
}
|
||||
$parts = $trimmed -split "\s+"
|
||||
if ($parts.Count -lt 5) {
|
||||
continue
|
||||
}
|
||||
$localEndpoint = $parts[1]
|
||||
$pidText = $parts[4]
|
||||
$localPort = -1
|
||||
if ($localEndpoint -match ":(\d+)$") {
|
||||
$localPort = [int]$matches[1]
|
||||
}
|
||||
if ($localPort -ne $Port) {
|
||||
continue
|
||||
}
|
||||
|
||||
$ownerPid = 0
|
||||
[void][int]::TryParse("$pidText", [ref]$ownerPid)
|
||||
$procName = "Unknown"
|
||||
if ($ownerPid -gt 0) {
|
||||
$proc = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
$procName = $proc.ProcessName
|
||||
}
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
Port = $Port
|
||||
PID = $ownerPid
|
||||
ProcessName = $procName
|
||||
LocalAddress = $localEndpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Test-PortAvailable {
|
||||
param([int]$Port)
|
||||
|
||||
try {
|
||||
$probe = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port)
|
||||
$probe.Start()
|
||||
$probe.Stop()
|
||||
return $true
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
Stop-Backend-By-Cmdline -MatchText "run_backend.py"
|
||||
Stop-Backend-By-Cmdline -MatchText "run_worker.py"
|
||||
$NginxProcName = Split-Path -Leaf $NginxExe
|
||||
$NginxProcName = $NginxProcName -replace '\.exe$', ''
|
||||
Stop-Process-By-Name -Name $NginxProcName -ExeName $NginxExe
|
||||
|
||||
$PortAvailable = Test-PortAvailable -Port $ServerPort
|
||||
if (-not $PortAvailable) {
|
||||
$PortOwner = Get-ListeningPortOwner -Port $ServerPort
|
||||
if ($PortOwner) {
|
||||
Write-Error (
|
||||
"Backend port $ServerPort is already in use by PID $($PortOwner.PID) " +
|
||||
"($($PortOwner.ProcessName)) on $($PortOwner.LocalAddress). " +
|
||||
"Please stop that process or change PORT in .env."
|
||||
)
|
||||
} else {
|
||||
Write-Error (
|
||||
"Backend port $ServerPort is not available (bind failed). " +
|
||||
"Please stop the process using this port or change PORT in .env."
|
||||
)
|
||||
}
|
||||
$global:LASTEXITCODE = 1
|
||||
return
|
||||
}
|
||||
|
||||
# 4. Runtime config + database pre-flight check
|
||||
Write-Host ">>> Checking deployment configuration..." -ForegroundColor Yellow
|
||||
|
||||
$UseCondaRun = -not [string]::IsNullOrWhiteSpace("$CondaEnvName".Trim())
|
||||
$ResolvedCondaExe = $null
|
||||
$CondaEnvPythonExe = $null
|
||||
|
||||
if ($UseCondaRun) {
|
||||
if ([string]::IsNullOrWhiteSpace("$CondaExe".Trim())) {
|
||||
$CondaExe = "conda"
|
||||
}
|
||||
$ResolvedCondaExe = Resolve-ExecutablePath -Candidate $CondaExe
|
||||
if (-not $ResolvedCondaExe) {
|
||||
Write-Error "Conda executable not found. CONDA_EXE=$CondaExe"
|
||||
$global:LASTEXITCODE = 1
|
||||
return
|
||||
}
|
||||
$CondaEnvPythonExe = Resolve-CondaEnvPythonPath -ResolvedCondaExe "$ResolvedCondaExe" -EnvName "$CondaEnvName"
|
||||
if (-not $CondaEnvPythonExe) {
|
||||
Write-Error "Conda environment python not found. CONDA_ENV_NAME=$CondaEnvName"
|
||||
$global:LASTEXITCODE = 1
|
||||
return
|
||||
}
|
||||
$PythonExe = $CondaEnvPythonExe
|
||||
} else {
|
||||
$ResolvedPythonExe = Resolve-ExecutablePath -Candidate $PythonExe
|
||||
if (-not $ResolvedPythonExe) {
|
||||
Write-Error "Python executable not found. PYTHON_PATH=$PythonExe"
|
||||
$global:LASTEXITCODE = 1
|
||||
return
|
||||
}
|
||||
$PythonExe = $ResolvedPythonExe
|
||||
}
|
||||
|
||||
$LauncherConfig = Invoke-PythonScriptJson -ScriptPath "$ExportLauncherConfigScript"
|
||||
if (-not $LauncherConfig) {
|
||||
Write-Error "Failed to load launcher runtime configuration from Python settings layer."
|
||||
$global:LASTEXITCODE = 1
|
||||
return
|
||||
}
|
||||
|
||||
if ($LauncherConfig.nginx_path) {
|
||||
$NginxExe = [string]$LauncherConfig.nginx_path
|
||||
}
|
||||
if ($LauncherConfig.backend_bind_host) {
|
||||
$ServerHost = [string]$LauncherConfig.backend_bind_host
|
||||
}
|
||||
if ($LauncherConfig.port) {
|
||||
$ServerPort = [int]$LauncherConfig.port
|
||||
}
|
||||
|
||||
function Write-StatusLine {
|
||||
param(
|
||||
[string]$Message,
|
||||
[ConsoleColor]$ForegroundColor = [ConsoleColor]::White
|
||||
)
|
||||
|
||||
Write-Host $Message -ForegroundColor $ForegroundColor
|
||||
}
|
||||
|
||||
function Get-BackgroundProcessLogs {
|
||||
param([string]$ScriptPath)
|
||||
|
||||
$LogDir = Join-Path $ProjectRoot "logs"
|
||||
if (-not (Test-Path -LiteralPath "$LogDir")) {
|
||||
New-Item -ItemType Directory -Path "$LogDir" -Force | Out-Null
|
||||
}
|
||||
|
||||
$ScriptBase = [System.IO.Path]::GetFileNameWithoutExtension("$ScriptPath")
|
||||
return [PSCustomObject]@{
|
||||
StdOut = Join-Path $LogDir "$ScriptBase.stdout.log"
|
||||
StdErr = Join-Path $LogDir "$ScriptBase.stderr.log"
|
||||
}
|
||||
}
|
||||
|
||||
function Start-PythonBackground {
|
||||
param([string]$ScriptPath)
|
||||
|
||||
$LogTargets = Get-BackgroundProcessLogs -ScriptPath "$ScriptPath"
|
||||
Write-Host " stdout -> $($LogTargets.StdOut)"
|
||||
Write-Host " stderr -> $($LogTargets.StdErr)"
|
||||
|
||||
return Start-Process `
|
||||
-FilePath "$PythonExe" `
|
||||
-ArgumentList @("$ScriptPath") `
|
||||
-WorkingDirectory "$ProjectRoot" `
|
||||
-PassThru `
|
||||
-NoNewWindow `
|
||||
-RedirectStandardOutput "$($LogTargets.StdOut)" `
|
||||
-RedirectStandardError "$($LogTargets.StdErr)"
|
||||
}
|
||||
|
||||
function Assert-ProcessAlive {
|
||||
param(
|
||||
[System.Diagnostics.Process]$Process,
|
||||
[string]$DisplayName
|
||||
)
|
||||
|
||||
if (-not $Process) {
|
||||
Write-Error "$DisplayName failed to start: process handle is null."
|
||||
$global:LASTEXITCODE = 1
|
||||
return $false
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 800
|
||||
$procCheck = Get-Process -Id $Process.Id -ErrorAction SilentlyContinue
|
||||
if (-not $procCheck) {
|
||||
Write-Error "$DisplayName exited immediately after startup. Please check logs for details."
|
||||
$global:LASTEXITCODE = 1
|
||||
return $false
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
Invoke-PythonScript -ScriptPath "$CheckRuntimeScript"
|
||||
if ($LastExitCode -ne 0) {
|
||||
Write-Host "`n[ERROR] Deployment configuration check failed." -ForegroundColor Red
|
||||
Write-Host "Please review the error messages above and fix the configuration." -ForegroundColor Red
|
||||
$global:LASTEXITCODE = 1
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host ">>> Checking database connection..." -ForegroundColor Yellow
|
||||
Invoke-PythonScript -ScriptPath "$CheckDbScript"
|
||||
if ($LastExitCode -ne 0) {
|
||||
Write-Host "`n[ERROR] Database connection check failed." -ForegroundColor Red
|
||||
Write-Host "Please review the error messages above and fix the configuration." -ForegroundColor Red
|
||||
$global:LASTEXITCODE = 1
|
||||
return
|
||||
}
|
||||
|
||||
# 4.5 Schema sync
|
||||
Write-Host ">>> Checking database schema..." -ForegroundColor Yellow
|
||||
Invoke-PythonScript -ScriptPath "$InitDbScript"
|
||||
if ($LastExitCode -ne 0) {
|
||||
Write-Host "`n[ERROR] Database schema check failed." -ForegroundColor Red
|
||||
Write-Host "Please review the error messages above and fix the configuration." -ForegroundColor Red
|
||||
$global:LASTEXITCODE = 1
|
||||
return
|
||||
}
|
||||
|
||||
# 5. Update Nginx config (absolute path fix)
|
||||
$NginxConfPath = Join-Path -Path $ProjectRoot -ChildPath "nginx\nginx.conf"
|
||||
if (Test-Path -LiteralPath "$NginxConfPath") {
|
||||
Write-Host ">>> Configuring Nginx paths..." -ForegroundColor Yellow
|
||||
|
||||
$NginxBase = Split-Path -Parent "$NginxExe"
|
||||
$SrcMime = Join-Path -Path $NginxBase -ChildPath "conf\mime.types"
|
||||
$DestMime = Join-Path -Path $ProjectRoot -ChildPath "nginx\mime.types"
|
||||
if (-not (Test-Path -LiteralPath "$DestMime") -and (Test-Path -LiteralPath "$SrcMime")) {
|
||||
Copy-Item -Path "$SrcMime" -Destination "$DestMime" -Force
|
||||
}
|
||||
|
||||
$ForwardRoot = $ProjectRoot.Replace([char]92, [char]47)
|
||||
$FrontendDist = "$ForwardRoot/frontend/dist"
|
||||
$ImageCache = "$ForwardRoot/backend/image_cache"
|
||||
|
||||
if (-not (Test-Path -LiteralPath "$ProjectRoot/backend/image_cache")) {
|
||||
New-Item -ItemType Directory -Path "$ProjectRoot/backend/image_cache" -Force | Out-Null
|
||||
}
|
||||
|
||||
$ConfContent = Get-Content -LiteralPath "$NginxConfPath" -Raw
|
||||
$NewConfContent = $ConfContent -replace 'root\s+[^;]+;', "root `"$FrontendDist`";"
|
||||
$NewConfContent = $NewConfContent -replace 'alias\s+[^;]+;', "alias `"$ImageCache/`";"
|
||||
$BackendProxy = "http://127.0.0.1:$ServerPort"
|
||||
$NewConfContent = $NewConfContent -replace 'proxy_pass\s+http://(127\.0\.0\.1|localhost):\d+;', "proxy_pass $BackendProxy;"
|
||||
# 使用 UTF8 无 BOM 编码写入
|
||||
$Utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
||||
[System.IO.File]::WriteAllText("$NginxConfPath", $NewConfContent, $Utf8NoBom)
|
||||
}
|
||||
|
||||
# 6. Start backend
|
||||
Write-Host ">>> Launching backend (FastAPI)..." -ForegroundColor Green
|
||||
if ($UseCondaRun) {
|
||||
Write-Host ">>> Using Conda env python: $PythonExe (env=$CondaEnvName)"
|
||||
} else {
|
||||
Write-Host ">>> Using Python: $PythonExe"
|
||||
}
|
||||
|
||||
$BackendProc = Start-PythonBackground -ScriptPath "run_backend.py"
|
||||
if (-not (Assert-ProcessAlive -Process $BackendProc -DisplayName "Backend")) {
|
||||
return
|
||||
}
|
||||
|
||||
# 6.5 Start job worker
|
||||
Write-Host ">>> Launching job worker..." -ForegroundColor Green
|
||||
$WorkerProc = Start-PythonBackground -ScriptPath "run_worker.py"
|
||||
if (-not (Assert-ProcessAlive -Process $WorkerProc -DisplayName "Worker")) {
|
||||
return
|
||||
}
|
||||
|
||||
# 7. Start Nginx
|
||||
if (Test-Path -LiteralPath "$NginxExe") {
|
||||
Write-Host ">>> Launching Nginx..." -ForegroundColor Green
|
||||
$NginxDir = Split-Path -Parent "$NginxExe"
|
||||
$NginxName = Split-Path -Leaf $NginxExe
|
||||
|
||||
$SafeConfPath = $NginxConfPath.Replace([char]92, [char]47)
|
||||
$NginxArgs = @("-c", "$SafeConfPath")
|
||||
$NginxLogs = Get-BackgroundProcessLogs -ScriptPath "$NginxName"
|
||||
Write-Host " stdout -> $($NginxLogs.StdOut)"
|
||||
Write-Host " stderr -> $($NginxLogs.StdErr)"
|
||||
$NginxProc = Start-Process `
|
||||
-FilePath "$NginxExe" `
|
||||
-ArgumentList $NginxArgs `
|
||||
-WorkingDirectory "$NginxDir" `
|
||||
-PassThru `
|
||||
-NoNewWindow `
|
||||
-RedirectStandardOutput "$($NginxLogs.StdOut)" `
|
||||
-RedirectStandardError "$($NginxLogs.StdErr)"
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
$NginxProcName = $NginxName -replace '\.exe$', ''
|
||||
$NginxRunning = Get-Process -Name $NginxProcName -ErrorAction SilentlyContinue
|
||||
|
||||
if ($NginxRunning) {
|
||||
$DisplayHost = "localhost"
|
||||
if ($ServerHost) { $DisplayHost = $ServerHost }
|
||||
Write-Host ""
|
||||
Write-StatusLine "============================================================" Green
|
||||
Write-StatusLine "SUCCESS: InSAR Management System is running!" Green
|
||||
Write-StatusLine "Frontend (via Nginx): http://$DisplayHost"
|
||||
Write-StatusLine "Backend (internal): http://127.0.0.1`:$ServerPort"
|
||||
Write-StatusLine "API Docs (internal): http://127.0.0.1`:$ServerPort/docs"
|
||||
Write-StatusLine "============================================================" Green
|
||||
Write-Host ""
|
||||
Write-StatusLine "System is running. Press Ctrl+C to stop all services." Yellow
|
||||
} else {
|
||||
Write-Warning "Nginx process not found. Check logs/nginx_error.log for details."
|
||||
}
|
||||
} else {
|
||||
Write-Warning "Nginx executable not found at: $NginxExe"
|
||||
}
|
||||
|
||||
# 8. Wait for exit
|
||||
try {
|
||||
Write-Host ""
|
||||
Write-StatusLine "Waiting for processes (Backend PID: $($BackendProc.Id), Worker PID: $($WorkerProc.Id))..." Cyan
|
||||
|
||||
$BackgroundJob = Register-ObjectEvent -InputObject $BackendProc -EventName "Exited" -Action { Write-Host "`nBackend process exited." -ForegroundColor Red } -ErrorAction SilentlyContinue
|
||||
|
||||
Wait-Process -Id $BackendProc.Id -ErrorAction SilentlyContinue
|
||||
|
||||
} finally {
|
||||
Write-Host "`nShutting down..." -ForegroundColor Yellow
|
||||
Stop-Backend-By-Cmdline -MatchText "run_backend.py"
|
||||
Stop-Backend-By-Cmdline -MatchText "run_worker.py"
|
||||
Stop-Process-By-Name -Name $NginxProcName -ExeName $NginxExe
|
||||
Write-Host "Done." -ForegroundColor Green
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import tarfile
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
ENV_PATH = os.path.join(PROJECT_ROOT, ".env")
|
||||
|
||||
# 使用统一的日志目录
|
||||
LOG_DIR = os.path.join(PROJECT_ROOT, "logs", "tasks", "unpacker")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
# 使用日期命名日志文件
|
||||
log_date = datetime.now().strftime("%Y%m%d")
|
||||
LOG_FILE = os.path.join(LOG_DIR, f"unpacker_{log_date}.json")
|
||||
REPORT_FILE = os.path.join(LOG_DIR, f"unpacker_{log_date}_report.txt")
|
||||
ACTIVITY_LOG = os.path.join(LOG_DIR, f"unpacker_{log_date}.log")
|
||||
|
||||
|
||||
class ProjWarningFilter(logging.Filter):
|
||||
"""过滤重复的 PROJ 数据库版本警告"""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.proj_warning_logged = False
|
||||
|
||||
def filter(self, record):
|
||||
# 检查是否是 PROJ 警告
|
||||
if "PROJ: proj_identify" in record.getMessage() and "DATABASE.LAYOUT.VERSION.MINOR" in record.getMessage():
|
||||
if self.proj_warning_logged:
|
||||
return False # 已经记录过,过滤掉
|
||||
else:
|
||||
self.proj_warning_logged = True
|
||||
# 修改消息,添加提示
|
||||
record.msg = record.msg + " (后续相同警告已过滤)"
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def load_env(path):
|
||||
env = {}
|
||||
if not os.path.exists(path):
|
||||
return env
|
||||
with open(path, "r", encoding="utf-8-sig") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, val = line.split("=", 1)
|
||||
env[key.strip()] = val.strip().strip('"').strip("'")
|
||||
return env
|
||||
|
||||
|
||||
def parse_dirs(value):
|
||||
if not value:
|
||||
return []
|
||||
value = value.replace(";", ",")
|
||||
return [p.strip() for p in value.split(",") if p.strip()]
|
||||
|
||||
|
||||
def parse_bool(value, default=False):
|
||||
if value is None:
|
||||
return default
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def get_disk_usage(path):
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
total, used, free = shutil.disk_usage(path)
|
||||
return total, used, free
|
||||
except FileNotFoundError:
|
||||
logging.error("disk usage failed for path: %s", path)
|
||||
return 0, 0, 0
|
||||
|
||||
|
||||
def find_archives(directories, extensions):
|
||||
archive_files = []
|
||||
for directory in directories:
|
||||
if not os.path.isdir(directory):
|
||||
logging.warning("source directory not found: %s", directory)
|
||||
continue
|
||||
for root, _, files in os.walk(directory):
|
||||
for file in files:
|
||||
lower = file.lower()
|
||||
if any(lower.endswith(ext) for ext in extensions):
|
||||
archive_files.append(os.path.join(root, file))
|
||||
return archive_files
|
||||
|
||||
|
||||
def load_progress(log_file):
|
||||
if os.path.exists(log_file):
|
||||
try:
|
||||
with open(log_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logging.warning("failed to read log file '%s': %s", log_file, e)
|
||||
return {"processed_files": [], "failed_files": []}
|
||||
|
||||
|
||||
def save_progress(log_file, progress):
|
||||
try:
|
||||
with open(log_file, "w", encoding="utf-8") as f:
|
||||
json.dump(progress, f, indent=2, ensure_ascii=False)
|
||||
except IOError as e:
|
||||
logging.error("failed to write log file '%s': %s", log_file, e)
|
||||
|
||||
|
||||
def create_report(report_file, reason, processed_count, remaining_count):
|
||||
try:
|
||||
with open(report_file, "w", encoding="utf-8") as f:
|
||||
f.write("--- Unpacker Report ---\n\n")
|
||||
f.write("Stopped at: %s\n" % datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
f.write("Reason: %s\n\n" % reason)
|
||||
f.write("Processed files: %s\n" % processed_count)
|
||||
f.write("Remaining files: %s\n" % remaining_count)
|
||||
logging.info("report written: %s", report_file)
|
||||
except IOError as e:
|
||||
logging.error("failed to write report '%s': %s", report_file, e)
|
||||
|
||||
|
||||
def _is_safe_tar_member(member_name):
|
||||
norm_name = os.path.normpath(member_name)
|
||||
if os.path.isabs(norm_name):
|
||||
return False
|
||||
if norm_name.startswith("..") or norm_name.startswith("../") or norm_name.startswith("..\\"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _validate_tar_members(tar_obj, archive_path):
|
||||
for member in tar_obj.getmembers():
|
||||
if not _is_safe_tar_member(member.name):
|
||||
raise IOError(f"unsafe tar entry detected: {member.name} in {archive_path}")
|
||||
|
||||
|
||||
def get_archive_uncompressed_size(archive_path):
|
||||
try:
|
||||
with tarfile.open(archive_path, "r:*") as tar:
|
||||
_validate_tar_members(tar, archive_path)
|
||||
return sum(m.size for m in tar.getmembers() if m.isfile())
|
||||
except (tarfile.TarError, FileNotFoundError, IsADirectoryError) as e:
|
||||
logging.error("failed to calculate size for '%s': %s", archive_path, e)
|
||||
return -1
|
||||
|
||||
|
||||
def pick_storage_dir(storage_dirs, required_bytes, min_free_bytes):
|
||||
candidates = []
|
||||
for d in storage_dirs:
|
||||
_, _, free = get_disk_usage(d)
|
||||
if (free - required_bytes) >= min_free_bytes:
|
||||
candidates.append((free, d))
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(reverse=True)
|
||||
return candidates[0][1]
|
||||
|
||||
|
||||
def _normalize_path(path):
|
||||
return os.path.normcase(os.path.abspath(path))
|
||||
|
||||
|
||||
def _resolve_target_root(archive_path, source_dirs, target_dirs):
|
||||
if not target_dirs:
|
||||
return None
|
||||
|
||||
if len(target_dirs) == 1:
|
||||
return target_dirs[0]
|
||||
|
||||
if source_dirs and len(source_dirs) == len(target_dirs):
|
||||
archive_norm = _normalize_path(archive_path)
|
||||
matches = []
|
||||
for idx, src in enumerate(source_dirs):
|
||||
src_norm = _normalize_path(src)
|
||||
if archive_norm == src_norm or archive_norm.startswith(src_norm + os.sep):
|
||||
matches.append((len(src_norm), idx))
|
||||
if matches:
|
||||
_, best_idx = max(matches)
|
||||
return target_dirs[best_idx]
|
||||
|
||||
return target_dirs[0]
|
||||
|
||||
|
||||
def atomic_extract(archive_path, output_dir, tmp_suffix):
|
||||
tmp_dir = output_dir + tmp_suffix
|
||||
lock_path = output_dir + ".unpacking"
|
||||
|
||||
if os.path.exists(output_dir):
|
||||
logging.warning("output exists, skip: %s", output_dir)
|
||||
return False
|
||||
if os.path.exists(tmp_dir):
|
||||
logging.warning("temp dir exists, skip: %s", tmp_dir)
|
||||
return False
|
||||
if os.path.exists(lock_path):
|
||||
logging.warning("lock exists, skip: %s", lock_path)
|
||||
return False
|
||||
|
||||
os.makedirs(tmp_dir, exist_ok=True)
|
||||
with open(lock_path, "w", encoding="utf-8") as f:
|
||||
f.write(datetime.now().isoformat())
|
||||
|
||||
try:
|
||||
with tarfile.open(archive_path, "r:*") as tar:
|
||||
_validate_tar_members(tar, archive_path)
|
||||
tar.extractall(path=tmp_dir)
|
||||
if not os.listdir(tmp_dir):
|
||||
raise IOError("extracted directory is empty")
|
||||
os.replace(tmp_dir, output_dir)
|
||||
return True
|
||||
finally:
|
||||
if os.path.exists(lock_path):
|
||||
try:
|
||||
os.remove(lock_path)
|
||||
except OSError:
|
||||
pass
|
||||
if os.path.exists(tmp_dir):
|
||||
try:
|
||||
shutil.rmtree(tmp_dir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def run_unpack_job(env_path=None, log_callback=None, progress_callback=None):
|
||||
def _log(level, message, *args):
|
||||
logging.log(level, message, *args)
|
||||
if log_callback:
|
||||
formatted = message % args if args else message
|
||||
log_callback(logging.getLevelName(level), formatted)
|
||||
|
||||
def _progress(progress, message):
|
||||
if progress_callback:
|
||||
progress_callback(progress, message)
|
||||
|
||||
# 配置日志过滤器
|
||||
proj_filter = ProjWarningFilter()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(ACTIVITY_LOG, "a", "utf-8"),
|
||||
logging.StreamHandler(),
|
||||
],
|
||||
)
|
||||
|
||||
# 为所有 handler 添加过滤器
|
||||
for handler in logging.root.handlers:
|
||||
handler.addFilter(proj_filter)
|
||||
|
||||
env = load_env(env_path or ENV_PATH)
|
||||
source_dirs = parse_dirs(env.get("UNPACK_SOURCE_DIRS"))
|
||||
target_dirs = parse_dirs(
|
||||
env.get("INSAR_STORAGE_DIRS")
|
||||
or env.get("UNPACK_TARGET_DIRS")
|
||||
or env.get("UNPACK_STORAGE_DIRS")
|
||||
)
|
||||
min_disk_gb = float(env.get("UNPACK_MIN_DISK_SPACE_GB", "50"))
|
||||
delete_archive = parse_bool(env.get("UNPACK_DELETE_ARCHIVE", "true"))
|
||||
tmp_suffix = env.get("UNPACK_TMP_SUFFIX", ".unpack_tmp")
|
||||
extensions = parse_dirs(env.get("UNPACK_ARCHIVE_EXTS", ".tar.gz"))
|
||||
|
||||
_log(logging.INFO, "=== start unpack job ===")
|
||||
|
||||
if not source_dirs:
|
||||
_log(logging.INFO, "no UNPACK_SOURCE_DIRS configured, exit")
|
||||
return {
|
||||
"processed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"total": 0,
|
||||
"message": "no source dirs configured",
|
||||
}
|
||||
|
||||
progress = load_progress(LOG_FILE)
|
||||
processed_files = set(progress.get("processed_files", []))
|
||||
|
||||
all_archives = find_archives(source_dirs, extensions)
|
||||
files_to_process = [f for f in all_archives if f not in processed_files]
|
||||
|
||||
_log(
|
||||
logging.INFO,
|
||||
"found %s archives, %s processed, %s pending",
|
||||
len(all_archives),
|
||||
len(processed_files),
|
||||
len(files_to_process),
|
||||
)
|
||||
|
||||
if not files_to_process:
|
||||
_log(logging.INFO, "nothing to do")
|
||||
return {
|
||||
"processed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"total": 0,
|
||||
"message": "nothing to do",
|
||||
}
|
||||
|
||||
min_space_bytes = min_disk_gb * (1024 ** 3)
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
total_files = len(files_to_process)
|
||||
for i, archive_path in enumerate(files_to_process):
|
||||
current_file_number = i + 1
|
||||
pct = int((i / max(total_files, 1)) * 100)
|
||||
_progress(pct, f"processing {current_file_number}/{total_files}")
|
||||
|
||||
_log(
|
||||
logging.INFO,
|
||||
"--- processing %s/%s: %s ---",
|
||||
current_file_number,
|
||||
total_files,
|
||||
archive_path,
|
||||
)
|
||||
|
||||
uncompressed_size_bytes = get_archive_uncompressed_size(archive_path)
|
||||
if uncompressed_size_bytes == -1:
|
||||
failure_record = {
|
||||
"file": archive_path,
|
||||
"error": "size_check_failed",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
progress.setdefault("failed_files", []).append(failure_record)
|
||||
save_progress(LOG_FILE, progress)
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
if target_dirs:
|
||||
target_root = _resolve_target_root(archive_path, source_dirs, target_dirs)
|
||||
if not target_root:
|
||||
target_root = target_dirs[0]
|
||||
_, _, free = get_disk_usage(target_root)
|
||||
if (free - uncompressed_size_bytes) < min_space_bytes:
|
||||
reason = (
|
||||
"insar_storage has insufficient free space\n"
|
||||
" needed: %.2f GB\n"
|
||||
" min free after: %.2f GB\n"
|
||||
" target: %s\n"
|
||||
% (
|
||||
uncompressed_size_bytes / (1024 ** 3),
|
||||
min_disk_gb,
|
||||
target_root,
|
||||
)
|
||||
)
|
||||
_log(logging.WARNING, reason)
|
||||
create_report(REPORT_FILE, reason, processed_count, total_files - i)
|
||||
return {
|
||||
"processed": processed_count,
|
||||
"failed": failed_count,
|
||||
"skipped": skipped_count,
|
||||
"total": total_files,
|
||||
"message": "insufficient free space",
|
||||
}
|
||||
else:
|
||||
target_root = os.path.dirname(archive_path)
|
||||
|
||||
base_name = os.path.basename(archive_path)
|
||||
for ext in [".tar.gz", ".tgz"]:
|
||||
if base_name.lower().endswith(ext):
|
||||
base_name = base_name[: -len(ext)]
|
||||
break
|
||||
output_dir = os.path.join(target_root, base_name)
|
||||
|
||||
try:
|
||||
ok = atomic_extract(archive_path, output_dir, tmp_suffix)
|
||||
if not ok:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
if delete_archive:
|
||||
os.remove(archive_path)
|
||||
|
||||
processed_files.add(archive_path)
|
||||
progress["processed_files"] = sorted(list(processed_files))
|
||||
save_progress(LOG_FILE, progress)
|
||||
_log(logging.INFO, "done: %s", archive_path)
|
||||
processed_count += 1
|
||||
|
||||
except (tarfile.TarError, IOError, OSError) as e:
|
||||
_log(logging.ERROR, "failed to process '%s': %s", archive_path, e)
|
||||
failure_record = {
|
||||
"file": archive_path,
|
||||
"error": str(e),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
progress.setdefault("failed_files", []).append(failure_record)
|
||||
save_progress(LOG_FILE, progress)
|
||||
failed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
_log(logging.CRITICAL, "unexpected error '%s': %s", archive_path, e)
|
||||
failure_record = {
|
||||
"file": archive_path,
|
||||
"error": "unexpected: %s" % e,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
progress.setdefault("failed_files", []).append(failure_record)
|
||||
save_progress(LOG_FILE, progress)
|
||||
failed_count += 1
|
||||
|
||||
pct = int(((i + 1) / max(total_files, 1)) * 100)
|
||||
_progress(pct, f"processed {current_file_number}/{total_files}")
|
||||
|
||||
if os.path.exists(REPORT_FILE):
|
||||
os.remove(REPORT_FILE)
|
||||
|
||||
_log(logging.INFO, "=== unpack job complete ===")
|
||||
return {
|
||||
"processed": processed_count,
|
||||
"failed": failed_count,
|
||||
"skipped": skipped_count,
|
||||
"total": total_files,
|
||||
"message": "completed",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
run_unpack_job()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Verify envipyengine task names for the 6-step custom D-InSAR workflow.
|
||||
|
||||
Run:
|
||||
python scripts/verify_envi_tasks.py
|
||||
"""
|
||||
import sys
|
||||
|
||||
TASK_NAMES = [
|
||||
"SARsInSARInterferogramGeneration",
|
||||
"SARsInSARFilterAndCoherence",
|
||||
"SARsInSARRemoveResidualPhaseFrequency",
|
||||
"SARsInSARPhaseUnwrapping",
|
||||
"SARsInSARRefinementAndReflattening",
|
||||
"SARsInSARPhaseToDisplacement",
|
||||
# metatask (already working)
|
||||
"SARsMetataskInSARDisplacementGeneration",
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
from envipyengine import Engine
|
||||
except ImportError:
|
||||
print("[ERROR] envipyengine not installed")
|
||||
return 1
|
||||
|
||||
engine = Engine("ENVI")
|
||||
ok = 0
|
||||
fail = 0
|
||||
|
||||
for name in TASK_NAMES:
|
||||
try:
|
||||
task = engine.task(name)
|
||||
params = task.parameters
|
||||
param_names = list(params.keys()) if isinstance(params, dict) else str(params)
|
||||
print(f"[OK] {name}")
|
||||
print(f" params: {param_names}")
|
||||
print()
|
||||
ok += 1
|
||||
except Exception as exc:
|
||||
print(f"[FAIL] {name}: {exc}")
|
||||
print()
|
||||
fail += 1
|
||||
|
||||
print(f"--- Result: {ok} ok, {fail} fail ---")
|
||||
return 0 if fail == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user