docs: update production workflow design and runtime changes

This commit is contained in:
2026-06-14 18:47:00 +08:00
parent 58a87706ef
commit b1c59051b9
41 changed files with 2042 additions and 134 deletions
+374
View File
@@ -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()))
+175
View File
@@ -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
View File
@@ -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
}
+94
View File
@@ -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