docs: update production workflow design and runtime changes
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
"""Inspect and optionally clean generated runtime data.
|
||||
|
||||
The script is deliberately conservative:
|
||||
- dry-run is the default;
|
||||
- protected paths are collected from database product/artifact references;
|
||||
- project source, local software, external data pools, and license files are never touched.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
BACKEND_DIR = PROJECT_ROOT / "backend"
|
||||
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from backend.app.config import ensure_project_env_loaded, read_int_env, settings # noqa: E402
|
||||
|
||||
|
||||
ACTIVE_STATUSES = {"PENDING", "READY", "RUNNING", "PROCESSING", "IN_PROGRESS", "QUEUED", "RETRY"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CleanupRoot:
|
||||
label: str
|
||||
path: Path
|
||||
default_retention_days: int
|
||||
mode: str = "children"
|
||||
protect_db_references: bool = False
|
||||
enabled_by_default: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class Candidate:
|
||||
label: str
|
||||
path: Path
|
||||
reason: str
|
||||
size_bytes: int
|
||||
files: int
|
||||
dirs: int
|
||||
last_write: datetime | None
|
||||
protected: bool = False
|
||||
deleted: bool = False
|
||||
error: str = ""
|
||||
|
||||
|
||||
def _norm(path: Path | str) -> Path:
|
||||
return Path(path).expanduser().resolve()
|
||||
|
||||
|
||||
def _is_under(path: Path, parent: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(parent)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _parse_dt(value: object) -> datetime | None:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
|
||||
try:
|
||||
return datetime.strptime(text[:26], fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _measure(path: Path) -> tuple[int, int, int, datetime | None]:
|
||||
if not path.exists():
|
||||
return 0, 0, 0, None
|
||||
if path.is_file():
|
||||
stat = path.stat()
|
||||
return stat.st_size, 1, 0, datetime.fromtimestamp(stat.st_mtime)
|
||||
|
||||
size = 0
|
||||
files = 0
|
||||
dirs = 0
|
||||
last_write = datetime.fromtimestamp(path.stat().st_mtime)
|
||||
for entry in path.rglob("*"):
|
||||
try:
|
||||
stat = entry.stat()
|
||||
except OSError:
|
||||
continue
|
||||
if entry.is_dir():
|
||||
dirs += 1
|
||||
else:
|
||||
files += 1
|
||||
size += stat.st_size
|
||||
mtime = datetime.fromtimestamp(stat.st_mtime)
|
||||
if mtime > last_write:
|
||||
last_write = mtime
|
||||
return size, files, dirs, last_write
|
||||
|
||||
|
||||
def _format_gb(size_bytes: int) -> str:
|
||||
return f"{size_bytes / (1024 ** 3):.3f} GB"
|
||||
|
||||
|
||||
def _candidate_children(root: CleanupRoot, cutoff: datetime) -> list[Candidate]:
|
||||
path = _norm(root.path)
|
||||
if not path.exists() or not path.is_dir():
|
||||
return []
|
||||
candidates: list[Candidate] = []
|
||||
for child in path.iterdir():
|
||||
size, files, dirs, last_write = _measure(child)
|
||||
if last_write and last_write > cutoff:
|
||||
continue
|
||||
candidates.append(
|
||||
Candidate(
|
||||
label=root.label,
|
||||
path=_norm(child),
|
||||
reason=f"last_write <= {cutoff.isoformat(timespec='seconds')}",
|
||||
size_bytes=size,
|
||||
files=files,
|
||||
dirs=dirs,
|
||||
last_write=last_write,
|
||||
)
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def _candidate_root(root: CleanupRoot, cutoff: datetime) -> list[Candidate]:
|
||||
path = _norm(root.path)
|
||||
if not path.exists():
|
||||
return []
|
||||
size, files, dirs, last_write = _measure(path)
|
||||
if last_write and last_write > cutoff:
|
||||
return []
|
||||
return [
|
||||
Candidate(
|
||||
label=root.label,
|
||||
path=path,
|
||||
reason=f"root last_write <= {cutoff.isoformat(timespec='seconds')}",
|
||||
size_bytes=size,
|
||||
files=files,
|
||||
dirs=dirs,
|
||||
last_write=last_write,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _configured_roots() -> list[CleanupRoot]:
|
||||
runtime_dir = BACKEND_DIR / "runtime"
|
||||
return [
|
||||
CleanupRoot(
|
||||
"pyint_work",
|
||||
Path(settings.PYINT_WORK_ROOT or runtime_dir / "pyint_work"),
|
||||
read_int_env("RUNTIME_CLEANUP_PYINT_WORK_RETENTION_DAYS", 14),
|
||||
),
|
||||
CleanupRoot(
|
||||
"pyint_dem",
|
||||
Path(settings.PYINT_DEM_ROOT or runtime_dir / "pyint_dem"),
|
||||
read_int_env("RUNTIME_CLEANUP_PYINT_DEM_RETENTION_DAYS", 60),
|
||||
),
|
||||
CleanupRoot(
|
||||
"sbas_insar_production_runs",
|
||||
Path(settings.GAMMA_SBAS_WORK_ROOT or runtime_dir / "sbas_insar_production") / "runs",
|
||||
read_int_env("RUNTIME_CLEANUP_SBAS_RUN_RETENTION_DAYS", 30),
|
||||
protect_db_references=True,
|
||||
),
|
||||
CleanupRoot(
|
||||
"gamma_ipta_trials",
|
||||
Path(settings.GAMMA_SBAS_TRIAL_ROOT or runtime_dir / "gamma_ipta_trials"),
|
||||
read_int_env("RUNTIME_CLEANUP_TRIAL_RETENTION_DAYS", 7),
|
||||
),
|
||||
CleanupRoot(
|
||||
"idl_worker_logs",
|
||||
Path(settings.IDL_WORKER_RUNTIME_DIR or runtime_dir / "idl_worker"),
|
||||
read_int_env("RUNTIME_CLEANUP_IDL_LOG_RETENTION_DAYS", 30),
|
||||
),
|
||||
CleanupRoot(
|
||||
"wsl_jobs",
|
||||
Path(settings.WSL_BROKER_JOB_ROOT or runtime_dir / "wsl_jobs"),
|
||||
read_int_env("RUNTIME_CLEANUP_WSL_JOB_RETENTION_DAYS", 14),
|
||||
),
|
||||
CleanupRoot(
|
||||
"timeseries_work",
|
||||
Path(settings.TIMESERIES_WORK_ROOT or runtime_dir / "timeseries_work"),
|
||||
read_int_env("RUNTIME_CLEANUP_TIMESERIES_WORK_RETENTION_DAYS", 30),
|
||||
),
|
||||
CleanupRoot(
|
||||
"image_cache",
|
||||
Path(settings.CACHE_DIR),
|
||||
read_int_env("RUNTIME_CLEANUP_IMAGE_CACHE_RETENTION_DAYS", 60),
|
||||
),
|
||||
CleanupRoot(
|
||||
"frontend_dist",
|
||||
PROJECT_ROOT / "frontend" / "dist",
|
||||
read_int_env("RUNTIME_CLEANUP_FRONTEND_DIST_RETENTION_DAYS", 0),
|
||||
mode="root",
|
||||
enabled_by_default=False,
|
||||
),
|
||||
CleanupRoot(
|
||||
"logs",
|
||||
PROJECT_ROOT / "logs",
|
||||
read_int_env("RUNTIME_CLEANUP_LOG_RETENTION_DAYS", 30),
|
||||
),
|
||||
CleanupRoot(
|
||||
"nginx_temp",
|
||||
PROJECT_ROOT / "nginx" / "temp",
|
||||
0,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def _db_rows(query: str) -> list[tuple]:
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
url = str(settings.DATABASE_URL or "").strip()
|
||||
if not url:
|
||||
return []
|
||||
engine = create_async_engine(url, pool_pre_ping=True)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(text(query))
|
||||
return list(result.all())
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _active_status_summary() -> dict[str, int]:
|
||||
queries = {
|
||||
"system_jobs": "select count(*) from system_jobs where upper(status) in ('PENDING','READY','RUNNING','PROCESSING','IN_PROGRESS','QUEUED','RETRY')",
|
||||
"system_tasks": "select count(*) from system_tasks where upper(status) in ('PENDING','RUNNING','PROCESSING','IN_PROGRESS','QUEUED')",
|
||||
"workflow_runs": "select count(*) from workflow_runs where upper(status) in ('PENDING','RUNNING','PROCESSING','IN_PROGRESS','QUEUED')",
|
||||
}
|
||||
summary: dict[str, int] = {}
|
||||
for key, query in queries.items():
|
||||
try:
|
||||
rows = await _db_rows(query)
|
||||
summary[key] = int(rows[0][0] or 0) if rows else 0
|
||||
except Exception:
|
||||
summary[key] = -1
|
||||
return summary
|
||||
|
||||
|
||||
async def _protected_paths() -> set[Path]:
|
||||
protected: set[Path] = set()
|
||||
queries = [
|
||||
"select publish_dir, manifest_path, source_primary_path, native_output_dir, preview_path, primary_asset_path from result_products",
|
||||
"select absolute_path from result_assets",
|
||||
"select path from workflow_artifacts",
|
||||
"select storage_root from result_catalog_states",
|
||||
]
|
||||
for query in queries:
|
||||
try:
|
||||
rows = await _db_rows(query)
|
||||
except Exception as exc:
|
||||
print(f"[WARN] Could not read protected paths: {exc}")
|
||||
continue
|
||||
for row in rows:
|
||||
for value in row:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
protected.add(_norm(text))
|
||||
except OSError:
|
||||
continue
|
||||
return protected
|
||||
|
||||
|
||||
def _apply_protection(candidates: Iterable[Candidate], protected: set[Path]) -> list[Candidate]:
|
||||
output: list[Candidate] = []
|
||||
for candidate in candidates:
|
||||
path = candidate.path
|
||||
for protected_path in protected:
|
||||
if path == protected_path or _is_under(protected_path, path) or _is_under(path, protected_path):
|
||||
candidate.protected = True
|
||||
candidate.reason = f"database reference protects {protected_path}"
|
||||
break
|
||||
output.append(candidate)
|
||||
return output
|
||||
|
||||
|
||||
def _delete(candidate: Candidate) -> None:
|
||||
try:
|
||||
if candidate.path.is_dir():
|
||||
shutil.rmtree(candidate.path)
|
||||
else:
|
||||
candidate.path.unlink()
|
||||
candidate.deleted = True
|
||||
except Exception as exc:
|
||||
candidate.error = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
|
||||
async def collect_candidates(include_optional: bool, include_external_runtime: bool) -> tuple[list[Candidate], dict[str, int]]:
|
||||
ensure_project_env_loaded()
|
||||
now = datetime.now()
|
||||
candidates: list[Candidate] = []
|
||||
for root in _configured_roots():
|
||||
if not root.enabled_by_default and not include_optional:
|
||||
continue
|
||||
root_path = _norm(root.path)
|
||||
if not _is_under(root_path, PROJECT_ROOT):
|
||||
if not include_external_runtime:
|
||||
print(f"[SKIP] {root.label}: outside project root: {root_path}")
|
||||
continue
|
||||
cutoff = now - timedelta(days=max(0, int(root.default_retention_days)))
|
||||
if root.mode == "root":
|
||||
items = _candidate_root(root, cutoff)
|
||||
else:
|
||||
items = _candidate_children(root, cutoff)
|
||||
candidates.extend(items)
|
||||
|
||||
protected = await _protected_paths()
|
||||
candidates = _apply_protection(candidates, protected)
|
||||
active_summary = await _active_status_summary()
|
||||
if any(value > 0 for value in active_summary.values()):
|
||||
for candidate in candidates:
|
||||
if candidate.label in {"pyint_work", "sbas_insar_production_runs", "wsl_jobs", "timeseries_work"}:
|
||||
candidate.protected = True
|
||||
candidate.reason = f"active database state present: {active_summary}"
|
||||
return candidates, active_summary
|
||||
|
||||
|
||||
def print_report(candidates: list[Candidate], active_summary: dict[str, int]) -> None:
|
||||
total = sum(item.size_bytes for item in candidates)
|
||||
deletable = sum(item.size_bytes for item in candidates if not item.protected)
|
||||
print(f"[INFO] Active DB state: {active_summary}")
|
||||
print(f"[INFO] Candidates: {len(candidates)}, total={_format_gb(total)}, deletable={_format_gb(deletable)}")
|
||||
for item in sorted(candidates, key=lambda x: x.size_bytes, reverse=True):
|
||||
status = "PROTECTED" if item.protected else ("DELETED" if item.deleted else "DELETE_CANDIDATE")
|
||||
last_write = item.last_write.isoformat(timespec="seconds") if item.last_write else "unknown"
|
||||
print(
|
||||
f"{status}\t{item.label}\t{_format_gb(item.size_bytes)}\t"
|
||||
f"files={item.files}\tdirs={item.dirs}\tlast={last_write}\t{item.path}\t{item.reason}"
|
||||
)
|
||||
if item.error:
|
||||
print(f" ERROR: {item.error}")
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Inspect and clean generated runtime data.")
|
||||
parser.add_argument("--delete", action="store_true", help="Actually delete unprotected candidates.")
|
||||
parser.add_argument("--include-optional", action="store_true", help="Include optional targets such as frontend/dist.")
|
||||
parser.add_argument("--include-external-runtime", action="store_true", help="Also inspect configured runtime roots outside the project.")
|
||||
parser.add_argument("--yes", action="store_true", help="Required together with --delete.")
|
||||
args = parser.parse_args()
|
||||
|
||||
candidates, active_summary = await collect_candidates(args.include_optional, args.include_external_runtime)
|
||||
if args.delete and not args.yes:
|
||||
print("[ERROR] --delete requires --yes")
|
||||
print_report(candidates, active_summary)
|
||||
return 2
|
||||
|
||||
if args.delete:
|
||||
for candidate in candidates:
|
||||
if candidate.protected:
|
||||
continue
|
||||
_delete(candidate)
|
||||
|
||||
print_report(candidates, active_summary)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Migrate repo-local runtime data to configured external roots."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
BACKEND_DIR = PROJECT_ROOT / "backend"
|
||||
LEGACY_RUNTIME = BACKEND_DIR / "runtime"
|
||||
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from backend.app.config import settings # noqa: E402
|
||||
from backend.app import database # noqa: E402
|
||||
from backend.app.services.sbas_insar_catalog_service import sbas_insar_catalog_service # noqa: E402
|
||||
from backend.app.services.sbas_insar_production_service import sbas_insar_production_service # noqa: E402
|
||||
|
||||
|
||||
def _norm(path: Path | str) -> Path:
|
||||
return Path(path).expanduser().resolve()
|
||||
|
||||
|
||||
def _is_under(path: Path, parent: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(parent)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _measure(path: Path) -> tuple[int, int]:
|
||||
if not path.exists():
|
||||
return 0, 0
|
||||
if path.is_file():
|
||||
return path.stat().st_size, 1
|
||||
size = 0
|
||||
files = 0
|
||||
for item in path.rglob("*"):
|
||||
if item.is_file():
|
||||
try:
|
||||
size += item.stat().st_size
|
||||
files += 1
|
||||
except OSError:
|
||||
continue
|
||||
return size, files
|
||||
|
||||
|
||||
def _gb(size: int) -> str:
|
||||
return f"{size / (1024 ** 3):.3f} GB"
|
||||
|
||||
|
||||
def _copy_tree(source: Path, target: Path, *, delete_source: bool) -> dict[str, object]:
|
||||
if not source.exists():
|
||||
return {"source": str(source), "target": str(target), "exists": False, "copied": False}
|
||||
if _norm(target) == _norm(source):
|
||||
return {"source": str(source), "target": str(target), "exists": True, "copied": False, "reason": "same_path"}
|
||||
if _is_under(_norm(target), _norm(source)):
|
||||
raise ValueError(f"refusing to copy {source} into itself: {target}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists():
|
||||
raise FileExistsError(f"target already exists: {target}")
|
||||
shutil.copytree(source, target)
|
||||
size, files = _measure(target)
|
||||
if delete_source:
|
||||
shutil.rmtree(source)
|
||||
return {
|
||||
"source": str(source),
|
||||
"target": str(target),
|
||||
"exists": True,
|
||||
"copied": True,
|
||||
"deleted_source": bool(delete_source),
|
||||
"size": _gb(size),
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def migrate_pyint_dem(*, apply: bool, delete_source: bool) -> dict[str, object]:
|
||||
source = LEGACY_RUNTIME / "pyint_dem"
|
||||
target = Path(settings.PYINT_DEM_ROOT)
|
||||
size, files = _measure(source)
|
||||
plan = {
|
||||
"source": str(source),
|
||||
"target": str(target),
|
||||
"source_exists": source.exists(),
|
||||
"source_size": _gb(size),
|
||||
"source_files": files,
|
||||
"apply": apply,
|
||||
"delete_source": delete_source,
|
||||
}
|
||||
if not apply:
|
||||
return plan
|
||||
return {**plan, "result": _copy_tree(source, target, delete_source=delete_source)}
|
||||
|
||||
|
||||
def sync_sbas_products(*, apply: bool) -> dict[str, object]:
|
||||
legacy_root = LEGACY_RUNTIME / "sbas_insar_production" / "runs"
|
||||
configured_work_root = Path(settings.GAMMA_SBAS_WORK_ROOT) / "runs"
|
||||
roots = []
|
||||
for root in (legacy_root, configured_work_root):
|
||||
if root.is_dir() and root not in roots:
|
||||
roots.append(root)
|
||||
run_ids = sorted(
|
||||
{
|
||||
path.parent.name
|
||||
for root in roots
|
||||
for path in root.glob("*/run_manifest.json")
|
||||
}
|
||||
)
|
||||
plan: dict[str, object] = {
|
||||
"source_roots": [str(root) for root in roots],
|
||||
"product_root": str(Path(settings.GAMMA_SBAS_PRODUCT_ROOT) / "runs"),
|
||||
"run_ids": run_ids,
|
||||
"apply": apply,
|
||||
}
|
||||
if not apply:
|
||||
return plan
|
||||
synced = []
|
||||
original_root = sbas_insar_production_service.production_root
|
||||
try:
|
||||
for run_id in run_ids:
|
||||
selected_root = next((root for root in roots if (root / run_id / "run_manifest.json").is_file()), None)
|
||||
if selected_root is None:
|
||||
continue
|
||||
sbas_insar_production_service.production_root = selected_root.parent
|
||||
synced.append(sbas_insar_production_service.sync_product_package(run_id))
|
||||
finally:
|
||||
sbas_insar_production_service.production_root = original_root
|
||||
plan["synced"] = synced
|
||||
return plan
|
||||
|
||||
|
||||
async def rebuild_sbas_catalog() -> dict[str, object]:
|
||||
if database.AsyncSessionLocal is None:
|
||||
database.init_db(settings.DATABASE_URL)
|
||||
if database.AsyncSessionLocal is None:
|
||||
raise RuntimeError("Database session factory is not initialized")
|
||||
async with database.AsyncSessionLocal() as db:
|
||||
return await sbas_insar_catalog_service.rebuild_catalog(db, full_rebuild=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--apply", action="store_true", help="perform the migration; default only prints the plan")
|
||||
parser.add_argument("--move-pyint-dem", action="store_true", help="copy legacy pyint_dem to PYINT_DEM_ROOT")
|
||||
parser.add_argument("--delete-legacy-pyint-dem", action="store_true", help="delete legacy pyint_dem after copy")
|
||||
parser.add_argument("--sync-sbas-products", action="store_true", help="sync lightweight SBAS products into GAMMA_SBAS_PRODUCT_ROOT")
|
||||
parser.add_argument("--rebuild-sbas-catalog", action="store_true", help="rebuild SBAS result catalog after syncing products")
|
||||
args = parser.parse_args()
|
||||
|
||||
actions: dict[str, object] = {}
|
||||
if args.move_pyint_dem:
|
||||
actions["pyint_dem"] = migrate_pyint_dem(apply=args.apply, delete_source=args.delete_legacy_pyint_dem)
|
||||
if args.sync_sbas_products:
|
||||
actions["sbas_products"] = sync_sbas_products(apply=args.apply)
|
||||
if args.rebuild_sbas_catalog:
|
||||
if args.apply:
|
||||
actions["sbas_catalog"] = asyncio.run(rebuild_sbas_catalog())
|
||||
else:
|
||||
actions["sbas_catalog"] = {"apply": False, "action": "rebuild_sbas_catalog"}
|
||||
if not actions:
|
||||
actions["message"] = "no actions selected"
|
||||
|
||||
import json
|
||||
|
||||
print(json.dumps(actions, indent=2, ensure_ascii=False, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+207
-1
@@ -29,6 +29,14 @@ $CondaEnvName = ""
|
||||
$NginxExe = "C:/nginx-1.29.4/nginx.exe"
|
||||
$ServerHost = ""
|
||||
$ServerPort = 18000
|
||||
$NginxAllowedClientIps = ""
|
||||
$BackendReadyTimeoutSeconds = 120
|
||||
$TileServerAutoStart = $false
|
||||
$TileServerAutoStop = $true
|
||||
$TileServerRoot = ""
|
||||
$TileServerStartScript = "start-all.bat"
|
||||
$TileServerStopScript = "stop-all.bat"
|
||||
$TileServerUrl = ""
|
||||
|
||||
$envLines = Get-Content -LiteralPath "$EnvPath"
|
||||
foreach ($line in $envLines) {
|
||||
@@ -43,7 +51,18 @@ foreach ($line in $envLines) {
|
||||
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 "NGINX_ALLOWED_CLIENT_IPS") { $NginxAllowedClientIps = $val }
|
||||
if ($key -eq "BACKEND_BIND_HOST") { if ($val) { $ServerHost = $val } }
|
||||
if ($key -eq "BACKEND_READY_TIMEOUT_SECONDS") {
|
||||
$parsed = 0
|
||||
if ([int]::TryParse($val, [ref]$parsed) -and $parsed -gt 0) { $BackendReadyTimeoutSeconds = $parsed }
|
||||
}
|
||||
if ($key -eq "TILE_SERVER_AUTO_START") { $TileServerAutoStart = $val -match '^(?i)(true|1|yes|on)$' }
|
||||
if ($key -eq "TILE_SERVER_AUTO_STOP") { $TileServerAutoStop = -not ($val -match '^(?i)(false|0|no|off)$') }
|
||||
if ($key -eq "TILE_SERVER_ROOT") { if ($val) { $TileServerRoot = $val } }
|
||||
if ($key -eq "TILE_SERVER_START_SCRIPT") { if ($val) { $TileServerStartScript = $val } }
|
||||
if ($key -eq "TILE_SERVER_STOP_SCRIPT") { if ($val) { $TileServerStopScript = $val } }
|
||||
if ($key -eq "VITE_TILE_SERVER_URL") { if ($val) { $TileServerUrl = $val.TrimEnd("/") } }
|
||||
if ($key -eq "PORT") {
|
||||
$parsed = 0
|
||||
if ([int]::TryParse($val, [ref]$parsed)) { $ServerPort = $parsed }
|
||||
@@ -315,11 +334,140 @@ function Test-PortAvailable {
|
||||
}
|
||||
}
|
||||
|
||||
function Write-NginxClientAllowFile {
|
||||
param([string]$Path)
|
||||
|
||||
$entries = @()
|
||||
$raw = "$NginxAllowedClientIps".Trim()
|
||||
if ($raw) {
|
||||
$entries = @($raw -split '[;,\s]+' | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
|
||||
}
|
||||
|
||||
$lines = @(
|
||||
"# Generated by scripts/start_app.ps1.",
|
||||
"# Configure NGINX_ALLOWED_CLIENT_IPS in .env. Empty means allow all clients."
|
||||
)
|
||||
|
||||
if ($entries.Count -gt 0) {
|
||||
$allowed = @("127.0.0.1", "::1") + $entries
|
||||
$allowed = @($allowed | Sort-Object -Unique)
|
||||
foreach ($item in $allowed) {
|
||||
$lines += " allow $item;"
|
||||
}
|
||||
$lines += " deny all;"
|
||||
}
|
||||
|
||||
$dir = Split-Path -Parent "$Path"
|
||||
if (-not (Test-Path -LiteralPath "$dir")) {
|
||||
New-Item -ItemType Directory -Path "$dir" -Force | Out-Null
|
||||
}
|
||||
$Utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
||||
[System.IO.File]::WriteAllText("$Path", (($lines -join [Environment]::NewLine) + [Environment]::NewLine), $Utf8NoBom)
|
||||
|
||||
if ($entries.Count -gt 0) {
|
||||
Write-Host ">>> Nginx client IP whitelist enabled: $($entries -join ', ')" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host ">>> Nginx client IP whitelist disabled." -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
|
||||
function Test-TileServerReady {
|
||||
if (-not $TileServerUrl) {
|
||||
return $false
|
||||
}
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri "$TileServerUrl/health" -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop
|
||||
return ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-TileServerScriptPath {
|
||||
param([string]$ScriptName)
|
||||
|
||||
$root = "$TileServerRoot".Trim().Trim('"').Trim("'")
|
||||
if (-not $root) {
|
||||
return $null
|
||||
}
|
||||
$script = "$ScriptName".Trim().Trim('"').Trim("'")
|
||||
if (-not $script) {
|
||||
return $null
|
||||
}
|
||||
if ([System.IO.Path]::IsPathRooted($script)) {
|
||||
return $script
|
||||
}
|
||||
return (Join-Path -Path $root -ChildPath $script)
|
||||
}
|
||||
|
||||
function Stop-TileServer {
|
||||
if (-not $TileServerAutoStop) {
|
||||
return
|
||||
}
|
||||
$stopScript = Get-TileServerScriptPath -ScriptName "$TileServerStopScript"
|
||||
if (-not $stopScript -or -not (Test-Path -LiteralPath "$stopScript")) {
|
||||
return
|
||||
}
|
||||
Write-Host ">>> Stopping tile-server..." -ForegroundColor Yellow
|
||||
$previousNoPause = $env:NO_PAUSE
|
||||
try {
|
||||
$env:NO_PAUSE = "1"
|
||||
& "$stopScript"
|
||||
} catch {
|
||||
Write-Warning "tile-server stop failed: $($_.Exception.Message)"
|
||||
} finally {
|
||||
if ($null -eq $previousNoPause) {
|
||||
Remove-Item Env:\NO_PAUSE -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:NO_PAUSE = $previousNoPause
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Start-TileServer {
|
||||
if (-not $TileServerAutoStart) {
|
||||
return
|
||||
}
|
||||
if (Test-TileServerReady) {
|
||||
Write-Host ">>> tile-server already responding: $TileServerUrl" -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
$startScript = Get-TileServerScriptPath -ScriptName "$TileServerStartScript"
|
||||
if (-not $startScript -or -not (Test-Path -LiteralPath "$startScript")) {
|
||||
Write-Error "tile-server start script not found. TILE_SERVER_ROOT=$TileServerRoot TILE_SERVER_START_SCRIPT=$TileServerStartScript"
|
||||
$global:LASTEXITCODE = 1
|
||||
return
|
||||
}
|
||||
Write-Host ">>> Launching tile-server..." -ForegroundColor Green
|
||||
$previousNoPause = $env:NO_PAUSE
|
||||
try {
|
||||
$env:NO_PAUSE = "1"
|
||||
& "$startScript"
|
||||
} finally {
|
||||
if ($null -eq $previousNoPause) {
|
||||
Remove-Item Env:\NO_PAUSE -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:NO_PAUSE = $previousNoPause
|
||||
}
|
||||
}
|
||||
for ($i = 0; $i -lt 20; $i++) {
|
||||
if (Test-TileServerReady) {
|
||||
Write-Host ">>> tile-server ready: $TileServerUrl" -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
Write-Warning "tile-server was started but did not pass health check: $TileServerUrl/health"
|
||||
}
|
||||
|
||||
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
|
||||
if ($TileServerAutoStart -and $TileServerAutoStop) {
|
||||
Stop-TileServer
|
||||
}
|
||||
|
||||
$PortAvailable = Test-PortAvailable -Port $ServerPort
|
||||
if (-not $PortAvailable) {
|
||||
@@ -455,6 +603,38 @@ function Assert-ProcessAlive {
|
||||
return $true
|
||||
}
|
||||
|
||||
function Wait-BackendReady {
|
||||
param(
|
||||
[int]$Port,
|
||||
[int]$TimeoutSeconds
|
||||
)
|
||||
|
||||
$deadline = (Get-Date).AddSeconds([Math]::Max(1, $TimeoutSeconds))
|
||||
# Use the lightweight root route for readiness. /api/health runs a full
|
||||
# operational self-check and can legitimately take longer during startup.
|
||||
$url = "http://127.0.0.1:$Port/"
|
||||
$lastError = $null
|
||||
|
||||
Write-Host ">>> Waiting for backend readiness: $url" -ForegroundColor Yellow
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri "$url" -UseBasicParsing -TimeoutSec 3 -ErrorAction Stop
|
||||
if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) {
|
||||
Write-Host ">>> Backend ready (status=$($response.StatusCode))." -ForegroundColor Green
|
||||
return $true
|
||||
}
|
||||
$lastError = "HTTP $($response.StatusCode)"
|
||||
} catch {
|
||||
$lastError = $_.Exception.Message
|
||||
}
|
||||
Start-Sleep -Milliseconds 800
|
||||
}
|
||||
|
||||
Write-Error "Backend did not become ready within $TimeoutSeconds seconds. Last error: $lastError"
|
||||
$global:LASTEXITCODE = 1
|
||||
return $false
|
||||
}
|
||||
|
||||
Invoke-PythonScript -ScriptPath "$CheckRuntimeScript"
|
||||
if ($LastExitCode -ne 0) {
|
||||
Write-Host "`n[ERROR] Deployment configuration check failed." -ForegroundColor Red
|
||||
@@ -506,16 +686,29 @@ if (Test-Path -LiteralPath "$NginxConfPath") {
|
||||
$ForwardRoot = $ProjectRoot.Replace([char]92, [char]47)
|
||||
$FrontendDist = "$ForwardRoot/frontend/dist"
|
||||
$ImageCache = "$ForwardRoot/backend/image_cache"
|
||||
$NginxClientAllowFile = Join-Path -Path $ProjectRoot -ChildPath "nginx\client_allow.conf"
|
||||
|
||||
if (-not (Test-Path -LiteralPath "$ProjectRoot/backend/image_cache")) {
|
||||
New-Item -ItemType Directory -Path "$ProjectRoot/backend/image_cache" -Force | Out-Null
|
||||
}
|
||||
Write-NginxClientAllowFile -Path "$NginxClientAllowFile"
|
||||
|
||||
$ConfContent = Get-Content -LiteralPath "$NginxConfPath" -Raw
|
||||
$NewConfContent = $ConfContent -replace 'root\s+[^;]+;', "root `"$FrontendDist`";"
|
||||
$NewConfContent = $NewConfContent -replace 'alias\s+[^;]+;', "alias `"$ImageCache/`";"
|
||||
$ClientAllowForwardPath = "$ForwardRoot/nginx/client_allow.conf"
|
||||
$NewConfContent = $NewConfContent -replace 'include\s+"[^"]*client_allow\.conf";', "include `"$ClientAllowForwardPath`";"
|
||||
$BackendProxy = "http://127.0.0.1:$ServerPort"
|
||||
$NewConfContent = $NewConfContent -replace 'proxy_pass\s+http://(127\.0\.0\.1|localhost):\d+;', "proxy_pass $BackendProxy;"
|
||||
$NewConfContent = [regex]::Replace(
|
||||
$NewConfContent,
|
||||
'(location\s+/api/\s*\{[\s\S]*?proxy_pass\s+)http://(127\.0\.0\.1|localhost):\d+(;)',
|
||||
"`${1}$BackendProxy`${3}"
|
||||
)
|
||||
$NewConfContent = [regex]::Replace(
|
||||
$NewConfContent,
|
||||
'(location\s+/api/tasks/active/stream\s*\{[\s\S]*?proxy_pass\s+)http://(127\.0\.0\.1|localhost):\d+(;)',
|
||||
"`${1}$BackendProxy`${3}"
|
||||
)
|
||||
# 使用 UTF8 无 BOM 编码写入
|
||||
$Utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
||||
[System.IO.File]::WriteAllText("$NginxConfPath", $NewConfContent, $Utf8NoBom)
|
||||
@@ -533,6 +726,9 @@ $BackendProc = Start-PythonBackground -ScriptPath "run_backend.py"
|
||||
if (-not (Assert-ProcessAlive -Process $BackendProc -DisplayName "Backend")) {
|
||||
return
|
||||
}
|
||||
if (-not (Wait-BackendReady -Port $ServerPort -TimeoutSeconds $BackendReadyTimeoutSeconds)) {
|
||||
return
|
||||
}
|
||||
|
||||
# 6.5 Start job worker
|
||||
Write-Host ">>> Launching job worker..." -ForegroundColor Green
|
||||
@@ -541,6 +737,12 @@ if (-not (Assert-ProcessAlive -Process $WorkerProc -DisplayName "Worker")) {
|
||||
return
|
||||
}
|
||||
|
||||
# 6.6 Start tile-server
|
||||
Start-TileServer
|
||||
if ($global:LASTEXITCODE -eq 1) {
|
||||
return
|
||||
}
|
||||
|
||||
# 7. Start Nginx
|
||||
if (Test-Path -LiteralPath "$NginxExe") {
|
||||
Write-Host ">>> Launching Nginx..." -ForegroundColor Green
|
||||
@@ -575,6 +777,9 @@ if (Test-Path -LiteralPath "$NginxExe") {
|
||||
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"
|
||||
if ($TileServerAutoStart -and $TileServerUrl) {
|
||||
Write-StatusLine "Tile Server: $TileServerUrl"
|
||||
}
|
||||
Write-StatusLine "============================================================" Green
|
||||
Write-Host ""
|
||||
Write-StatusLine "System is running. Press Ctrl+C to stop all services." Yellow
|
||||
@@ -599,5 +804,6 @@ try {
|
||||
Stop-Backend-By-Cmdline -MatchText "run_backend.py"
|
||||
Stop-Backend-By-Cmdline -MatchText "run_worker.py"
|
||||
Stop-Process-By-Name -Name $NginxProcName -ExeName $NginxExe
|
||||
Stop-TileServer
|
||||
Write-Host "Done." -ForegroundColor Green
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# InSAR Management System - Stop Script (PowerShell)
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$ProjectRoot = Split-Path -Parent $ScriptDir
|
||||
Set-Location -LiteralPath "$ProjectRoot"
|
||||
|
||||
$EnvPath = Join-Path $ProjectRoot ".env"
|
||||
$NginxExe = "C:/nginx-1.29.4/nginx.exe"
|
||||
$TileServerAutoStop = $true
|
||||
$TileServerRoot = ""
|
||||
$TileServerStopScript = "stop-all.bat"
|
||||
|
||||
if (Test-Path -LiteralPath "$EnvPath") {
|
||||
foreach ($line in (Get-Content -LiteralPath "$EnvPath")) {
|
||||
$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 "NGINX_PATH" -and $val) { $NginxExe = $val }
|
||||
if ($key -eq "TILE_SERVER_AUTO_STOP") { $TileServerAutoStop = -not ($val -match '^(?i)(false|0|no|off)$') }
|
||||
if ($key -eq "TILE_SERVER_ROOT" -and $val) { $TileServerRoot = $val }
|
||||
if ($key -eq "TILE_SERVER_STOP_SCRIPT" -and $val) { $TileServerStopScript = $val }
|
||||
}
|
||||
}
|
||||
|
||||
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*") {
|
||||
Write-Host "Stopping python PID $($proc.ProcessId): $MatchText"
|
||||
Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-Process-By-Name {
|
||||
param([string]$Name, [string]$ExeName)
|
||||
$projectMatch = Join-Path -Path $ProjectRoot -ChildPath "nginx"
|
||||
$matched = @()
|
||||
$candidates = Get-CimInstance Win32_Process -Filter "Name='$Name.exe'" -ErrorAction SilentlyContinue
|
||||
foreach ($proc in ($candidates | Where-Object { $_ })) {
|
||||
if ($proc.CommandLine -and $proc.CommandLine -like "*$projectMatch*") {
|
||||
$matched += $proc
|
||||
}
|
||||
}
|
||||
foreach ($proc in $matched) {
|
||||
Write-Host "Stopping $Name PID $($proc.ProcessId)"
|
||||
Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-TileServer {
|
||||
if (-not $TileServerAutoStop -or -not $TileServerRoot) {
|
||||
return
|
||||
}
|
||||
$script = if ([System.IO.Path]::IsPathRooted($TileServerStopScript)) {
|
||||
$TileServerStopScript
|
||||
} else {
|
||||
Join-Path -Path $TileServerRoot -ChildPath $TileServerStopScript
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath "$script")) {
|
||||
Write-Warning "tile-server stop script not found: $script"
|
||||
return
|
||||
}
|
||||
Write-Host "Stopping tile-server..."
|
||||
$previousNoPause = $env:NO_PAUSE
|
||||
try {
|
||||
$env:NO_PAUSE = "1"
|
||||
& "$script"
|
||||
} finally {
|
||||
if ($null -eq $previousNoPause) {
|
||||
Remove-Item Env:\NO_PAUSE -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:NO_PAUSE = $previousNoPause
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ">>> Stopping InSAR Management System V2..." -ForegroundColor Yellow
|
||||
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
|
||||
|
||||
Stop-TileServer
|
||||
Write-Host "Done." -ForegroundColor Green
|
||||
Reference in New Issue
Block a user