Rewrite the gateway around health, retries, slicing and a Windows service

Scanner
- manifest lookup is now depth-bounded and prunes numeric zoom directories, so
  a scan no longer walks the whole tile payload
- rescans only report a version as updated when something actually changed
- overlapping runs are refused instead of interleaving their bookkeeping

Hot path
- token authorisation loads only that token grants and throttles the
  last_used_at write instead of touching it on every tile
- SQLite runs in WAL with a busy timeout and a real connection pool
- the catalog lookup used by /tiles no longer loads every basemap

Security
- the admin surface requires ADMIN_TOKEN (bearer) or ADMIN_USER/ADMIN_PASSWORD
  (basic), and the service refuses to bind off-host without credentials
- CORS is an explicit allow-list; newly minted tokens never reach a redirect URL
- client tokens are redacted from the access log

Operations
- resource health inspection flags a moved or deleted tile root before a 404
- pushes retry with backoff and can be re-queued by hand; the schedule lives in
  the database so a restart does not lose it
- access log plus /metrics counters

Image slicing
- tile_jobs drive the GDAL bundled with QGIS via scripts/gdal2tiles-qgis.bat
- layer_sources remembers what a layer is made of: submit one source to append
  a flight, or all of them to rebuild the layer as a VRT mosaic with no tile
  seams; the console exposes both, including a one-click rebuild
- slicing is available to integrations through a narrow slice token scope
  (submit, poll, idempotent by code, completion callback)

Console
- rebuilt around a fixed rail and single-view navigation with health and
  outstanding work leading, instead of one long scrolling page

Windows service
- native install/start/stop/status with crash recovery, auto-start and a
  rotating log file, so no NSSM wrapper is needed
- start-all.bat and stop-all.bat are removed: they killed the process by port
  with taskkill, which fought the service restart policy

Verified with 49 tests and live runs against real tile data and real GDAL.
This commit is contained in:
2026-10-16 10:35:30 +08:00
parent 8faf25b436
commit 73ad03fa59
49 changed files with 8668 additions and 1817 deletions
+62
View File
@@ -0,0 +1,62 @@
# Demo Scripts
This folder contains runnable demos for the internal remote-sensing result service.
## Prerequisites
- PostgreSQL and PostGIS are available to the API through `config/service.env`.
- The Python environment is available at `PYTHON_EXE`.
- Go is installed and can run `go run`.
## Service Startup
You can start the stack manually from the repository root:
```powershell
.\start-all.bat
```
Or use the self-contained full-flow demo to start a temporary stack on demo ports:
```powershell
.\demo\demo_full_flow.ps1 -StartStack -StartCallbackReceiver
```
## Demo List
- `demo_import_and_slice.ps1`
Imports a raster result, binds a style, and triggers COG, preview, legend, and tile generation.
- `demo_register_target_system.ps1`
Creates or updates a callback-based target system.
- `demo_push_result.ps1`
Pushes an existing managed result to a target system or callback URL.
- `demo_geojson_flow.ps1`
Registers a GeoJSON resource and optionally pushes it through the same delivery interface.
- `demo_full_flow.ps1`
Runs the main end-to-end flow: sample raster, import, slice, target registration, and callback push.
- `demo_callback_receiver.py`
Small local receiver for inspecting push payloads.
## Suggested Order
1. Start the services manually, or run `demo_full_flow.ps1 -StartStack -StartCallbackReceiver`.
2. Run `demo_import_and_slice.ps1` to create a managed result.
3. Run `demo_register_target_system.ps1` to register a callback receiver.
4. Run `demo_push_result.ps1` to publish the managed result.
5. Run `demo_geojson_flow.ps1 -Push` if you want to verify the retained GeoJSON delivery path.
## Example Commands
```powershell
.\demo\demo_import_and_slice.ps1
.\demo\demo_register_target_system.ps1 -CallbackUrl http://127.0.0.1:8920/callback
.\demo\demo_push_result.ps1 -ResultCode demo_result_20260406_120000 -TargetSystemCode demo_callback
.\demo\demo_geojson_flow.ps1 -Push -CallbackUrl http://127.0.0.1:8920/callback
.\demo\demo_full_flow.ps1 -StartStack -StartCallbackReceiver
```
## Output
Each demo prints a JSON summary that includes the main identifiers and URLs. Temporary run data is written under `temp/demo-runs/`.
`source_system_code` is intentionally left empty in the demos. If you want to populate it, seed the `source_systems` table first.
+574
View File
@@ -0,0 +1,574 @@
param(
[string]$EnvFile = (Join-Path $PSScriptRoot "..\config\service.env")
)
$script:RsDemoRepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$script:RsDemoProcesses = @()
$script:RsDemoEnvLoaded = $false
function Get-RsRepoRoot {
return $script:RsDemoRepoRoot
}
function Import-RsDemoEnvironment {
param([string]$Path = $EnvFile)
if ($script:RsDemoEnvLoaded) {
return
}
if (Test-Path -LiteralPath $Path) {
foreach ($rawLine in (Get-Content -LiteralPath $Path)) {
$line = $rawLine.Trim()
if (-not $line -or $line.StartsWith("#")) {
continue
}
if ($line.StartsWith("export ")) {
$line = $line.Substring(7).Trim()
}
$parts = $line.Split("=", 2)
if ($parts.Count -ne 2) {
continue
}
$key = $parts[0].Trim()
$value = $parts[1].Trim().Trim("`"").Trim("'")
if (-not $key) {
continue
}
if (-not (Test-Path "Env:$key")) {
Set-Item -Path "Env:$key" -Value $value
}
}
}
if (-not $env:PYTHON_EXE) {
$env:PYTHON_EXE = "C:\Users\Administrator\.conda\envs\InSAR\python.exe"
}
if (-not $env:API_BASE_URL) {
$env:API_BASE_URL = "http://127.0.0.1:8910"
}
if (-not $env:TILE_STATIC_BASE_URL) {
$env:TILE_STATIC_BASE_URL = "http://127.0.0.1:8900"
}
if (-not $env:RS_API_BASE_URL) {
$env:RS_API_BASE_URL = $env:API_BASE_URL
}
if (-not $env:PYTHONIOENCODING) {
$env:PYTHONIOENCODING = "utf-8"
}
if (-not $env:NO_PAUSE) {
$env:NO_PAUSE = "1"
}
$script:RsDemoEnvLoaded = $true
}
function Get-RsApiBaseUrl {
Import-RsDemoEnvironment
return ([string]$env:API_BASE_URL).TrimEnd("/")
}
function Get-RsTileBaseUrl {
Import-RsDemoEnvironment
return ([string]$env:TILE_STATIC_BASE_URL).TrimEnd("/")
}
function Get-RsPythonExe {
Import-RsDemoEnvironment
if (-not (Test-Path -LiteralPath $env:PYTHON_EXE)) {
throw "Python interpreter not found: $($env:PYTHON_EXE)"
}
return $env:PYTHON_EXE
}
function Get-RsGoCommand {
return (Get-Command go -ErrorAction Stop).Source
}
function New-RsRunContext {
param([string]$Prefix = "demo")
$runId = "{0}_{1}" -f $Prefix, (Get-Date -Format "yyyyMMdd_HHmmss")
$runDir = Join-Path (Get-RsRepoRoot) ("temp\demo-runs\" + $runId)
$logDir = Join-Path $runDir "logs"
$outputDir = Join-Path $runDir "output"
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
return [pscustomobject]@{
RunID = $runId
RunDirectory = $runDir
LogDirectory = $logDir
OutputDirectory = $outputDir
}
}
function ConvertTo-RsFileUri {
param([Parameter(Mandatory)] [string]$Path)
$fullPath = [System.IO.Path]::GetFullPath($Path)
return ([System.Uri]$fullPath).AbsoluteUri
}
function Encode-RsURIComponent {
param([Parameter(Mandatory)] [string]$Value)
return [System.Uri]::EscapeDataString($Value)
}
function Wait-RsUrl {
param(
[Parameter(Mandatory)] [string]$Url,
[int]$TimeoutSec = 60
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
try {
Invoke-RestMethod -Uri $Url -Method Get -TimeoutSec 5 | Out-Null
return
} catch {
Start-Sleep -Milliseconds 500
}
} while ((Get-Date) -lt $deadline)
throw "Timed out waiting for $Url"
}
function Invoke-RsApi {
param(
[Parameter(Mandatory)] [string]$Method,
[Parameter(Mandatory)] [string]$Path,
[object]$Body,
[int]$TimeoutSec = 30
)
Import-RsDemoEnvironment
$uri = if ($Path -match "^https?://") {
$Path
} else {
"{0}{1}" -f (Get-RsApiBaseUrl), $Path
}
$params = @{
Uri = $uri
Method = $Method
TimeoutSec = $TimeoutSec
Headers = @{
Accept = "application/json"
}
}
if ($PSBoundParameters.ContainsKey("Body") -and $null -ne $Body) {
$params.ContentType = "application/json"
$params.Body = ($Body | ConvertTo-Json -Depth 32)
}
try {
$response = Invoke-RestMethod @params
} catch {
$message = $_.Exception.Message
$webResponse = $_.Exception.Response
if ($webResponse) {
try {
$stream = $webResponse.GetResponseStream()
if ($stream) {
$reader = New-Object System.IO.StreamReader($stream)
$rawBody = $reader.ReadToEnd()
$reader.Dispose()
if ($rawBody) {
try {
$errorEnvelope = $rawBody | ConvertFrom-Json
if ($errorEnvelope.error) {
$message = "{0} {1}" -f $errorEnvelope.error.code, $errorEnvelope.error.message
} else {
$message = $rawBody
}
} catch {
$message = $rawBody
}
}
}
} catch {
}
}
throw "API call failed: $Method $uri`n$message"
}
if ($null -eq $response) {
return $null
}
if ($response.error) {
throw "API error: $Method $uri => $($response.error.code) $($response.error.message)"
}
return $response.data
}
function Wait-RsJob {
param(
[Parameter(Mandatory)] [string]$JobID,
[int]$TimeoutSec = 300,
[int]$PollIntervalSec = 2
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
$job = Invoke-RsApi -Method "GET" -Path ("/api/v1/jobs/{0}" -f $JobID)
if ($job.status -eq "succeeded") {
return $job
}
if ($job.status -in @("failed", "cancelled")) {
throw "Job $JobID finished with status=$($job.status) error=$($job.last_error_message)"
}
Start-Sleep -Seconds $PollIntervalSec
} while ((Get-Date) -lt $deadline)
throw "Timed out waiting for job $JobID"
}
function Get-RsStyleByCode {
param(
[Parameter(Mandatory)] [string]$ProjectCode,
[Parameter(Mandatory)] [string]$Code,
[switch]$AllowMissing
)
$project = Encode-RsURIComponent $ProjectCode
$keyword = Encode-RsURIComponent $Code
$items = @(Invoke-RsApi -Method "GET" -Path "/api/v1/styles?project_code=$project&keyword=$keyword")
$match = $items | Where-Object { $_.code -eq $Code } | Select-Object -First 1
if ($match) {
return $match
}
if ($AllowMissing) {
return $null
}
throw "Style not found: $Code"
}
function Get-RsTargetSystemByCode {
param(
[Parameter(Mandatory)] [string]$ProjectCode,
[Parameter(Mandatory)] [string]$Code,
[switch]$AllowMissing
)
$project = Encode-RsURIComponent $ProjectCode
$items = @(Invoke-RsApi -Method "GET" -Path "/api/v1/target-systems?project_code=$project")
$match = $items | Where-Object { $_.code -eq $Code } | Select-Object -First 1
if ($match) {
return $match
}
if ($AllowMissing) {
return $null
}
throw "Target system not found: $Code"
}
function Get-RsResultByCode {
param(
[Parameter(Mandatory)] [string]$ProjectCode,
[Parameter(Mandatory)] [string]$Code,
[switch]$AllowMissing
)
$project = Encode-RsURIComponent $ProjectCode
$keyword = Encode-RsURIComponent $Code
$items = @(Invoke-RsApi -Method "GET" -Path "/api/v1/projects/$project/results?keyword=$keyword")
$match = $items | Where-Object { $_.code -eq $Code } | Select-Object -First 1
if ($match) {
return Invoke-RsApi -Method "GET" -Path ("/api/v1/results/{0}" -f $match.id)
}
if ($AllowMissing) {
return $null
}
throw "Result not found: $Code"
}
function New-RsDemoStyleSpec {
param(
[string]$StyleID = "demo_continuous_style",
[string]$Name = "Demo Continuous Style",
[string]$Scope = "project",
[double]$MinValue = -1.0,
[double]$MaxValue = 1.0,
[int]$TileSize = 256,
[string]$TileFormat = "webp",
[int]$MinZoom = 0,
[int]$MaxZoom = 4
)
return @{
spec_version = "1.0"
id = $StyleID
name = $Name
version = 1
scope = $Scope
render = @{
mode = "continuous"
band = 1
resampling = "bilinear"
value_domain = @{
min = $MinValue
max = $MaxValue
}
color_map = @{
type = "linear"
stops = @(
@{ value = $MinValue; color = "#8c510a"; label = "low" },
@{ value = (($MinValue + $MaxValue) / 2.0); color = "#f6e8c3"; label = "mid" },
@{ value = $MaxValue; color = "#01665e"; label = "high" }
)
null_color = "#00000000"
}
}
legend = @{
title = $Name
orientation = "horizontal"
precision = 2
}
output = @{
tile_format = $TileFormat
tile_size = $TileSize
min_zoom = $MinZoom
max_zoom = $MaxZoom
}
}
}
function Ensure-RsStyleVersion {
param(
[Parameter(Mandatory)] [string]$ProjectCode,
[Parameter(Mandatory)] [string]$StyleCode,
[string]$StyleName = "Demo Continuous Style",
[hashtable]$StyleSpec,
[switch]$CreateNewVersion
)
$style = Get-RsStyleByCode -ProjectCode $ProjectCode -Code $StyleCode -AllowMissing
if (-not $style) {
$style = Invoke-RsApi -Method "POST" -Path "/api/v1/styles" -Body @{
project_code = $ProjectCode
scope = "project"
code = $StyleCode
name = $StyleName
created_by = "demo"
}
}
if (-not $CreateNewVersion -and $style.latest_version_id) {
return Invoke-RsApi -Method "GET" -Path ("/api/v1/style-versions/{0}" -f $style.latest_version_id)
}
if (-not $StyleSpec) {
$StyleSpec = New-RsDemoStyleSpec -StyleID $StyleCode -Name $StyleName
}
return Invoke-RsApi -Method "POST" -Path ("/api/v1/styles/{0}/versions" -f $style.id) -Body @{
style_spec = $StyleSpec
}
}
function Ensure-RsTargetSystem {
param(
[Parameter(Mandatory)] [string]$ProjectCode,
[Parameter(Mandatory)] [string]$Code,
[Parameter(Mandatory)] [string]$Name,
[Parameter(Mandatory)] [string]$CallbackUrl,
[string]$CallbackMethod = "POST",
[hashtable]$CallbackHeaders,
[int]$TimeoutSeconds = 30,
[bool]$Enabled = $true,
[string]$CreatedBy = "demo"
)
$body = @{
code = $Code
name = $Name
callback_url = $CallbackUrl
callback_method = $CallbackMethod
timeout_seconds = $TimeoutSeconds
enabled = $Enabled
callback_headers = $CallbackHeaders
}
$existing = Get-RsTargetSystemByCode -ProjectCode $ProjectCode -Code $Code -AllowMissing
if ($existing) {
return Invoke-RsApi -Method "PATCH" -Path ("/api/v1/target-systems/{0}" -f $existing.id) -Body $body
}
$body.project_code = $ProjectCode
$body.created_by = $CreatedBy
return Invoke-RsApi -Method "POST" -Path "/api/v1/target-systems" -Body $body
}
function Start-RsBackgroundProcess {
param(
[Parameter(Mandatory)] [string]$FilePath,
[string[]]$ArgumentList,
[Parameter(Mandatory)] [string]$WorkingDirectory,
[Parameter(Mandatory)] [string]$Name,
[Parameter(Mandatory)] [string]$LogDirectory
)
New-Item -ItemType Directory -Force -Path $LogDirectory | Out-Null
$stdout = Join-Path $LogDirectory ($Name + ".stdout.log")
$stderr = Join-Path $LogDirectory ($Name + ".stderr.log")
$process = Start-Process -FilePath $FilePath `
-ArgumentList $ArgumentList `
-WorkingDirectory $WorkingDirectory `
-PassThru `
-RedirectStandardOutput $stdout `
-RedirectStandardError $stderr
$script:RsDemoProcesses += $process
return $process
}
function Stop-RsBackgroundProcesses {
param([object[]]$Processes = $script:RsDemoProcesses)
foreach ($process in $Processes) {
if ($process -and -not $process.HasExited) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
}
}
}
function Start-RsDemoStack {
param(
[int]$ApiPort = 28910,
[int]$TilePort = 28900,
[switch]$IncludeWorker,
[string]$RunID = ("stack_" + (Get-Date -Format "yyyyMMdd_HHmmss"))
)
Import-RsDemoEnvironment
$runDir = Join-Path (Get-RsRepoRoot) ("temp\demo-runs\" + $RunID)
$logDir = Join-Path $runDir "logs"
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$apiBaseUrl = "http://127.0.0.1:$ApiPort"
$tileBaseUrl = "http://127.0.0.1:$TilePort"
$env:API_HOST = "127.0.0.1"
$env:API_PORT = "$ApiPort"
$env:API_BASE_URL = $apiBaseUrl
$env:TILE_HOST = "127.0.0.1"
$env:TILE_PORT = "$TilePort"
$env:TILE_STATIC_BASE_URL = $tileBaseUrl
$env:RS_API_BASE_URL = $apiBaseUrl
$env:NO_PAUSE = "1"
$pythonExe = Get-RsPythonExe
$goExe = Get-RsGoCommand
$tileProcess = Start-RsBackgroundProcess `
-FilePath $pythonExe `
-ArgumentList @((Join-Path (Get-RsRepoRoot) "server.py")) `
-WorkingDirectory (Get-RsRepoRoot) `
-Name "tile-server" `
-LogDirectory $logDir
$apiProcess = Start-RsBackgroundProcess `
-FilePath $goExe `
-ArgumentList @("run", ".\cmd\api") `
-WorkingDirectory (Join-Path (Get-RsRepoRoot) "services\api-go") `
-Name "api-service" `
-LogDirectory $logDir
Wait-RsUrl -Url "$tileBaseUrl/health" -TimeoutSec 90
Wait-RsUrl -Url "$apiBaseUrl/ready" -TimeoutSec 120
$workerProcess = $null
if ($IncludeWorker) {
$workerProcess = Start-RsBackgroundProcess `
-FilePath $pythonExe `
-ArgumentList @((Join-Path (Get-RsRepoRoot) "services\worker-py\main.py")) `
-WorkingDirectory (Get-RsRepoRoot) `
-Name "python-worker" `
-LogDirectory $logDir
Start-Sleep -Seconds 1
}
$processes = @($tileProcess, $apiProcess)
if ($workerProcess) {
$processes += $workerProcess
}
return [pscustomobject]@{
RunID = $RunID
RunDirectory = $runDir
LogDirectory = $logDir
ApiBaseUrl = $apiBaseUrl
TileBaseUrl = $tileBaseUrl
Processes = $processes
}
}
function Start-RsCallbackReceiver {
param(
[int]$Port = 28920,
[string]$Path = "/callback",
[string]$RunID = ("callback_" + (Get-Date -Format "yyyyMMdd_HHmmss"))
)
$runDir = Join-Path (Get-RsRepoRoot) ("temp\demo-runs\" + $RunID)
$logDir = Join-Path $runDir "logs"
$outputDir = Join-Path $runDir "callback-records"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
$pythonExe = Get-RsPythonExe
$process = Start-RsBackgroundProcess `
-FilePath $pythonExe `
-ArgumentList @(
(Join-Path $PSScriptRoot "demo_callback_receiver.py"),
"--host", "127.0.0.1",
"--port", "$Port",
"--path", $Path,
"--output-dir", $outputDir
) `
-WorkingDirectory (Get-RsRepoRoot) `
-Name "callback-receiver" `
-LogDirectory $logDir
$callbackUrl = "http://127.0.0.1:$Port$Path"
Wait-RsUrl -Url "http://127.0.0.1:$Port/health" -TimeoutSec 30
return [pscustomobject]@{
RunID = $RunID
RunDirectory = $runDir
LogDirectory = $logDir
OutputDirectory = $outputDir
CallbackURL = $callbackUrl
Process = $process
}
}
function Get-RsLatestCallbackRecord {
param([Parameter(Mandatory)] [string]$OutputDirectory)
if (-not (Test-Path -LiteralPath $OutputDirectory)) {
return $null
}
$file = Get-ChildItem -LiteralPath $OutputDirectory -Filter "*.json" |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (-not $file) {
return $null
}
return [pscustomobject]@{
Path = $file.FullName
Payload = (Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json)
}
}
+86
View File
@@ -0,0 +1,86 @@
import argparse
import os
import sys
from pathlib import Path
def bootstrap_gdal_env() -> None:
env_root = Path(sys.executable).resolve().parent
gdal_paths = [
env_root / "Lib" / "site-packages" / "rasterio" / "gdal_data",
env_root / "Library" / "share" / "gdal",
env_root / "share" / "gdal",
]
proj_paths = [
env_root / "Lib" / "site-packages" / "rasterio" / "proj_data",
env_root / "Library" / "share" / "proj",
env_root / "share" / "proj",
]
for path in gdal_paths:
if path.exists():
os.environ["GDAL_DATA"] = str(path)
break
for path in proj_paths:
if path.exists():
os.environ["PROJ_LIB"] = str(path)
os.environ["PROJ_DATA"] = str(path)
break
bootstrap_gdal_env()
import numpy as np
import rasterio
from rasterio.transform import from_origin
def build_values(width: int, height: int, preset: str) -> np.ndarray:
yy, xx = np.mgrid[0:height, 0:width]
x = (xx / max(width - 1, 1)).astype("float32")
y = (yy / max(height - 1, 1)).astype("float32")
if preset == "phase":
values = np.sin((x * 8.0) + (y * 4.0)) * np.cos(y * 10.0)
else:
values = (x * 2.0 - 1.0) * 0.7 + np.sin(y * np.pi * 3.0) * 0.3
return values.astype("float32")
def main() -> int:
parser = argparse.ArgumentParser(description="Create a sample GeoTIFF for demos.")
parser.add_argument("--output", required=True, help="Output GeoTIFF path.")
parser.add_argument("--width", type=int, default=512)
parser.add_argument("--height", type=int, default=512)
parser.add_argument("--preset", choices=["ndvi", "phase"], default="ndvi")
parser.add_argument("--origin-x", type=float, default=110.0)
parser.add_argument("--origin-y", type=float, default=22.0)
parser.add_argument("--pixel-size", type=float, default=0.01)
args = parser.parse_args()
output = Path(args.output).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
data = build_values(args.width, args.height, args.preset)
with rasterio.open(
output,
"w",
driver="GTiff",
width=args.width,
height=args.height,
count=1,
dtype="float32",
crs="EPSG:4326",
transform=from_origin(args.origin_x, args.origin_y, args.pixel_size, args.pixel_size),
nodata=-9999.0,
) as dst:
dst.write(data, 1)
print(output)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+109
View File
@@ -0,0 +1,109 @@
import argparse
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
def parse_args():
parser = argparse.ArgumentParser(description="Simple callback receiver for tile-server demos.")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8920)
parser.add_argument("--path", default="/callback")
parser.add_argument("--output-dir", required=True)
return parser.parse_args()
class State:
def __init__(self, output_dir: Path, callback_path: str):
self.output_dir = output_dir
self.callback_path = callback_path
self.counter = 0
self.lock = threading.Lock()
def next_file(self) -> Path:
with self.lock:
self.counter += 1
name = f"{time.strftime('%Y%m%d_%H%M%S')}_{self.counter:04d}.json"
return self.output_dir / name
def make_handler(state: State):
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps({"status": "ok"}).encode("utf-8"))
return
self.send_response(404)
self.end_headers()
def do_POST(self):
self._handle_payload()
def do_PUT(self):
self._handle_payload()
def _handle_payload(self):
if self.path != state.callback_path:
self.send_response(404)
self.end_headers()
return
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length)
text = raw.decode("utf-8", errors="replace")
try:
body_json = json.loads(text)
except json.JSONDecodeError:
body_json = None
record = {
"received_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"method": self.command,
"path": self.path,
"headers": {key: value for key, value in self.headers.items()},
"body_text": text,
"body_json": body_json,
}
output_path = state.next_file()
output_path.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"[callback] saved {output_path}", flush=True)
response = {"saved": str(output_path), "ok": True}
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps(response).encode("utf-8"))
def log_message(self, fmt, *args):
print("[callback] " + (fmt % args), flush=True)
return Handler
def main():
args = parse_args()
output_dir = Path(args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
state = State(output_dir=output_dir, callback_path=args.path)
server = ThreadingHTTPServer((args.host, args.port), make_handler(state))
print(f"Callback receiver listening on http://{args.host}:{args.port}{args.path}", flush=True)
print(f"Health endpoint: http://{args.host}:{args.port}/health", flush=True)
print(f"Output dir: {output_dir}", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()
+213
View File
@@ -0,0 +1,213 @@
param(
[string]$ProjectCode = "default",
[string]$ResultCode = ("demo_full_" + (Get-Date -Format "yyyyMMdd_HHmmss")),
[string]$ResultType = "ndvi",
[string]$SourceSystemCode = "",
[string]$RequestedBy = "demo-user",
[string]$StyleCode = "demo_continuous_style",
[string]$TargetSystemCode = "demo_callback",
[switch]$StartStack,
[switch]$StartCallbackReceiver,
[switch]$KeepProcesses,
[int]$ApiPort = 28910,
[int]$TilePort = 28900,
[int]$CallbackPort = 28920,
[int]$ZoomMin = 0,
[int]$ZoomMax = 3
)
. (Join-Path $PSScriptRoot "common.ps1")
$stack = $null
$callback = $null
$run = New-RsRunContext -Prefix "full_flow"
try {
Import-RsDemoEnvironment
if ($StartStack) {
$stack = Start-RsDemoStack -ApiPort $ApiPort -TilePort $TilePort -IncludeWorker -RunID $run.RunID
}
$callbackUrl = ""
if ($StartCallbackReceiver) {
$callback = Start-RsCallbackReceiver -Port $CallbackPort -RunID ($run.RunID + "_callback")
$callbackUrl = $callback.CallbackURL
}
$inputPath = Join-Path $run.OutputDirectory ($ResultCode + ".tif")
& (Get-RsPythonExe) (Join-Path $PSScriptRoot "create_sample_raster.py") `
--output $inputPath `
--preset "ndvi" | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to create sample raster"
}
$inspection = Invoke-RsApi -Method "POST" -Path "/api/v1/admin/raster-inspections" -Body @{
path = [System.IO.Path]::GetFullPath($inputPath)
}
$styleVersion = Ensure-RsStyleVersion -ProjectCode $ProjectCode -StyleCode $StyleCode -StyleName "Demo Continuous Style"
$project = Encode-RsURIComponent $ProjectCode
$importBody = @{
requested_by = $RequestedBy
asset = @{
source_ref = $inspection.storage_uri
asset_type = "raster"
display_name = $ResultCode
description = "Imported by demo_full_flow.ps1"
metadata = @{
demo_run = $run.RunID
}
}
asset_version = @{
format = $inspection.format
storage_uri = $inspection.storage_uri
mime_type = $inspection.mime_type
size_bytes = $inspection.size_bytes
crs = $inspection.crs
width = $inspection.width
height = $inspection.height
band_count = $inspection.band_count
dtype = $inspection.dtype
nodata_value = $inspection.nodata_value
bbox = $inspection.bbox
resolution_x = $inspection.resolution_x
resolution_y = $inspection.resolution_y
metadata = $inspection.metadata
}
product = @{
code = $ResultCode
type = $ResultType
name = $ResultCode
description = "Demo end-to-end result"
}
product_version = @{
value_type = "continuous"
unit = "index"
bbox = $inspection.bbox
status = "ready"
}
style_binding = @{
style_version_id = $styleVersion.id
is_default = $true
availability = "enabled"
}
}
if ($SourceSystemCode) {
$importBody.source_system_code = $SourceSystemCode
}
$import = Invoke-RsApi -Method "POST" -Path "/api/v1/projects/$project/imports/results" -Body $importBody
$slice = Invoke-RsApi -Method "POST" -Path ("/api/v1/results/{0}/slice" -f $import.product.product.id) -Body @{
requested_by = $RequestedBy
build_cog = $true
generate_preview = $true
generate_legend = $true
generate_tiles = $true
build_cog_input = @{
compression = "deflate"
block_size = 256
force_rebuild = $true
}
preview_input = @{
style_version_id = $styleVersion.id
width = 512
height = 512
format = "png"
force_rebuild = $true
}
legend_input = @{
style_version_id = $styleVersion.id
width = 320
height = 80
format = "png"
force_rebuild = $true
}
tiles_input = @{
style_version_id = $styleVersion.id
zoom_min = $ZoomMin
zoom_max = $ZoomMax
tile_size = 256
tile_format = "webp"
force_rebuild = $true
}
}
$sliceJobs = @()
foreach ($job in @($slice.jobs)) {
$sliceJobs += Wait-RsJob -JobID $job.id
}
if ($callbackUrl) {
Ensure-RsTargetSystem `
-ProjectCode $ProjectCode `
-Code $TargetSystemCode `
-Name "Demo Callback Receiver" `
-CallbackUrl $callbackUrl `
-CallbackMethod "POST" `
-TimeoutSeconds 30 `
-CreatedBy $RequestedBy `
-CallbackHeaders @{
"X-Demo-Mode" = "full-flow"
} | Out-Null
}
$pushBody = @{
requested_by = $RequestedBy
style_version_id = $styleVersion.id
include_artifacts = $true
}
if ($callbackUrl) {
$pushBody.target_system_code = $TargetSystemCode
} else {
$pushBody.callback_url = "http://127.0.0.1:8920/callback"
}
$push = Invoke-RsApi -Method "POST" -Path ("/api/v1/results/{0}/pushes" -f $import.product.product.id) -Body $pushBody
$pushJob = Wait-RsJob -JobID $push.job.id
$delivery = Invoke-RsApi -Method "GET" -Path ("/api/v1/projects/{0}/products/{1}/delivery?version=latest&style_version_id={2}" -f `
(Encode-RsURIComponent $ProjectCode), `
(Encode-RsURIComponent $ResultCode), `
(Encode-RsURIComponent $styleVersion.id))
$callbackRecord = $null
if ($callback) {
Start-Sleep -Seconds 1
$callbackRecord = Get-RsLatestCallbackRecord -OutputDirectory $callback.OutputDirectory
}
[ordered]@{
run_id = $run.RunID
api_base_url = Get-RsApiBaseUrl
tile_base_url = Get-RsTileBaseUrl
callback_url = if ($callback) { $callback.CallbackURL } else { $push.callback_url }
result_id = $import.product.product.id
result_code = $ResultCode
product_version_id = $import.product_version.id
style_version_id = $styleVersion.id
slice_job_ids = @($sliceJobs | ForEach-Object { $_.id })
push_job_id = $push.job.id
push_job_status = $pushJob.status
cog_url = $delivery.downloads.cog_url
preview_url = if ($delivery.preview) { $delivery.preview.url } else { $null }
legend_url = if ($delivery.legend) { $delivery.legend.url } else { $null }
tileset_url = if ($delivery.tileset) { $delivery.tileset.url_template } else { $null }
callback_record_path = if ($callbackRecord) { $callbackRecord.Path } else { $null }
run_directory = $run.RunDirectory
log_directory = if ($stack) { $stack.LogDirectory } else { $null }
} | ConvertTo-Json -Depth 16
}
finally {
if (-not $KeepProcesses) {
if ($callback) {
Stop-RsBackgroundProcesses -Processes @($callback.Process)
}
if ($stack) {
Stop-RsBackgroundProcesses -Processes $stack.Processes
}
}
}
+135
View File
@@ -0,0 +1,135 @@
param(
[string]$ProjectCode = "default",
[string]$ResourceCode = ("demo_geojson_" + (Get-Date -Format "yyyyMMdd_HHmmss")),
[string]$InputPath = "",
[string]$RequestedBy = "demo-user",
[switch]$Push,
[string]$TargetSystemCode = "",
[string]$CallbackUrl = "",
[bool]$WaitForJob = $true,
[switch]$StartStack,
[switch]$StartCallbackReceiver,
[switch]$KeepProcesses,
[int]$ApiPort = 28910,
[int]$TilePort = 28900,
[int]$CallbackPort = 28920
)
. (Join-Path $PSScriptRoot "common.ps1")
$stack = $null
$callback = $null
$run = New-RsRunContext -Prefix "geojson_flow"
try {
Import-RsDemoEnvironment
if ($StartStack) {
$stack = Start-RsDemoStack -ApiPort $ApiPort -TilePort $TilePort -IncludeWorker -RunID $run.RunID
}
if ($StartCallbackReceiver) {
$callback = Start-RsCallbackReceiver -Port $CallbackPort -RunID ($run.RunID + "_callback")
$Push = $true
$CallbackUrl = $callback.CallbackURL
}
if (-not $InputPath) {
$geojsonDir = Join-Path (Get-RsRepoRoot) "data\geojson\demo"
New-Item -ItemType Directory -Force -Path $geojsonDir | Out-Null
$InputPath = Join-Path $geojsonDir ($ResourceCode + ".geojson")
@"
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"code": "$ResourceCode",
"kind": "demo"
},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[110.0, 20.0],
[112.0, 20.0],
[112.0, 22.0],
[110.0, 22.0],
[110.0, 20.0]
]
]
}
}
]
}
"@ | Set-Content -LiteralPath $InputPath -Encoding UTF8
}
$publicUrl = "/geojson/demo/" + [System.IO.Path]::GetFileName($InputPath)
$project = Encode-RsURIComponent $ProjectCode
$resource = Invoke-RsApi -Method "POST" -Path "/api/v1/projects/$project/geojson-resources" -Body @{
code = $ResourceCode
name = $ResourceCode
description = "Registered by demo_geojson_flow.ps1"
storage_uri = ConvertTo-RsFileUri $InputPath
public_url = $publicUrl
metadata = @{
demo_run = $run.RunID
}
}
$pushResponse = $null
$job = $null
if ($Push -or $TargetSystemCode -or $CallbackUrl) {
$body = @{
resource_kind = "geojson_resource"
geojson_resource_id = $resource.id
requested_by = $RequestedBy
}
if ($TargetSystemCode) {
$body.target_system_code = $TargetSystemCode
}
if ($CallbackUrl) {
$body.callback_url = $CallbackUrl
}
if (-not $TargetSystemCode -and -not $CallbackUrl) {
$body.callback_url = "http://127.0.0.1:8920/callback"
}
$pushResponse = Invoke-RsApi -Method "POST" -Path "/api/v1/projects/$project/delivery-pushes" -Body $body
if ($WaitForJob) {
$job = Wait-RsJob -JobID $pushResponse.job.id
}
}
$callbackRecord = $null
if ($callback) {
Start-Sleep -Seconds 1
$callbackRecord = Get-RsLatestCallbackRecord -OutputDirectory $callback.OutputDirectory
}
[ordered]@{
run_id = $run.RunID
api_base_url = Get-RsApiBaseUrl
tile_base_url = Get-RsTileBaseUrl
resource_id = $resource.id
resource_code = $resource.code
input_path = [System.IO.Path]::GetFullPath($InputPath)
public_url = $resource.public_url
download_url = $resource.download_url
push_job_id = if ($pushResponse) { $pushResponse.job.id } else { $null }
push_job_status = if ($job) { $job.status } elseif ($pushResponse) { $pushResponse.job.status } else { $null }
callback_url = if ($pushResponse) { $pushResponse.callback_url } else { $CallbackUrl }
callback_record_path = if ($callbackRecord) { $callbackRecord.Path } else { $null }
} | ConvertTo-Json -Depth 12
}
finally {
if (-not $KeepProcesses) {
if ($callback) {
Stop-RsBackgroundProcesses -Processes @($callback.Process)
}
if ($stack) {
Stop-RsBackgroundProcesses -Processes $stack.Processes
}
}
}
+190
View File
@@ -0,0 +1,190 @@
param(
[string]$ProjectCode = "default",
[string]$ResultCode = ("demo_result_" + (Get-Date -Format "yyyyMMdd_HHmmss")),
[string]$ResultType = "ndvi",
[string]$InputPath = "",
[string]$SourceSystemCode = "",
[string]$RequestedBy = "demo-user",
[string]$StyleCode = "demo_continuous_style",
[string]$StyleName = "Demo Continuous Style",
[int]$ZoomMin = 0,
[int]$ZoomMax = 3,
[bool]$WaitForJobs = $true,
[switch]$StartStack,
[switch]$KeepStack,
[int]$ApiPort = 28910,
[int]$TilePort = 28900
)
. (Join-Path $PSScriptRoot "common.ps1")
$stack = $null
$run = New-RsRunContext -Prefix "import_slice"
try {
Import-RsDemoEnvironment
if ($StartStack) {
$stack = Start-RsDemoStack -ApiPort $ApiPort -TilePort $TilePort -IncludeWorker -RunID $run.RunID
}
if (-not $InputPath) {
$InputPath = Join-Path $run.OutputDirectory ($ResultCode + ".tif")
& (Get-RsPythonExe) (Join-Path $PSScriptRoot "create_sample_raster.py") `
--output $InputPath `
--preset "ndvi" | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to create sample raster"
}
}
$inspection = Invoke-RsApi -Method "POST" -Path "/api/v1/admin/raster-inspections" -Body @{
path = [System.IO.Path]::GetFullPath($InputPath)
}
$styleVersion = Ensure-RsStyleVersion -ProjectCode $ProjectCode -StyleCode $StyleCode -StyleName $StyleName
$assetVersionBody = @{
format = $inspection.format
storage_uri = $inspection.storage_uri
mime_type = $inspection.mime_type
size_bytes = $inspection.size_bytes
crs = $inspection.crs
width = $inspection.width
height = $inspection.height
band_count = $inspection.band_count
dtype = $inspection.dtype
bbox = $inspection.bbox
metadata = $inspection.metadata
}
if ($inspection.PSObject.Properties.Name -contains "nodata_value" -and $null -ne $inspection.nodata_value) {
$assetVersionBody.nodata_value = $inspection.nodata_value
}
if ($inspection.PSObject.Properties.Name -contains "resolution_x" -and $null -ne $inspection.resolution_x) {
$assetVersionBody.resolution_x = $inspection.resolution_x
}
if ($inspection.PSObject.Properties.Name -contains "resolution_y" -and $null -ne $inspection.resolution_y) {
$assetVersionBody.resolution_y = $inspection.resolution_y
}
$project = Encode-RsURIComponent $ProjectCode
$importBody = @{
requested_by = $RequestedBy
asset = @{
source_ref = $inspection.storage_uri
asset_type = "raster"
display_name = $ResultCode
description = "Imported by demo_import_and_slice.ps1"
metadata = @{
demo_run = $run.RunID
}
}
asset_version = $assetVersionBody
product = @{
code = $ResultCode
type = $ResultType
name = $ResultCode
description = "Demo managed result"
}
product_version = @{
value_type = "continuous"
unit = "index"
bbox = $inspection.bbox
status = "ready"
}
style_binding = @{
style_version_id = $styleVersion.id
is_default = $true
availability = "enabled"
}
}
if ($SourceSystemCode) {
$importBody.source_system_code = $SourceSystemCode
}
$import = Invoke-RsApi -Method "POST" -Path "/api/v1/projects/$project/imports/results" -Body $importBody
$slice = Invoke-RsApi -Method "POST" -Path ("/api/v1/results/{0}/slice" -f $import.product.product.id) -Body @{
requested_by = $RequestedBy
build_cog = $true
generate_preview = $true
generate_legend = $true
generate_tiles = $true
build_cog_input = @{
compression = "deflate"
block_size = 256
force_rebuild = $true
}
preview_input = @{
style_version_id = $styleVersion.id
width = 512
height = 512
format = "png"
force_rebuild = $true
}
legend_input = @{
style_version_id = $styleVersion.id
width = 320
height = 80
format = "png"
force_rebuild = $true
}
tiles_input = @{
style_version_id = $styleVersion.id
zoom_min = $ZoomMin
zoom_max = $ZoomMax
tile_size = 256
tile_format = "webp"
force_rebuild = $true
}
}
$jobStates = @()
if ($WaitForJobs) {
foreach ($job in @($slice.jobs)) {
$jobStates += Wait-RsJob -JobID $job.id
}
}
$result = Invoke-RsApi -Method "GET" -Path ("/api/v1/results/{0}" -f $import.product.product.id)
$delivery = $null
if ($WaitForJobs) {
$delivery = Invoke-RsApi -Method "GET" -Path ("/api/v1/projects/{0}/products/{1}/delivery?version=latest&style_version_id={2}" -f `
(Encode-RsURIComponent $ProjectCode), `
(Encode-RsURIComponent $ResultCode), `
(Encode-RsURIComponent $styleVersion.id))
}
[ordered]@{
run_id = $run.RunID
api_base_url = Get-RsApiBaseUrl
tile_base_url = Get-RsTileBaseUrl
input_path = [System.IO.Path]::GetFullPath($InputPath)
result_id = $import.product.product.id
result_code = $ResultCode
product_version_id = $import.product_version.id
style_version_id = $styleVersion.id
imported_asset_id = $import.asset.id
imported_version_id = $import.asset_version.id
slice_job_ids = @($slice.jobs | ForEach-Object { $_.id })
slice_job_statuses = @($jobStates | ForEach-Object {
[ordered]@{
id = $_.id
job_type = $_.job_type
status = $_.status
}
})
cog_url = if ($delivery) { $delivery.downloads.cog_url } else { $null }
preview_url = if ($delivery -and $delivery.preview) { $delivery.preview.url } else { $null }
legend_url = if ($delivery -and $delivery.legend) { $delivery.legend.url } else { $null }
tileset_url = if ($delivery -and $delivery.tileset) { $delivery.tileset.url_template } else { $null }
result_status = $result.status
run_directory = $run.RunDirectory
log_directory = if ($stack) { $stack.LogDirectory } else { $null }
} | ConvertTo-Json -Depth 16
}
finally {
if ($stack -and -not $KeepStack) {
Stop-RsBackgroundProcesses -Processes $stack.Processes
}
}
+72
View File
@@ -0,0 +1,72 @@
param(
[string]$ProjectCode = "default",
[string]$ResultCode = "",
[string]$ResultID = "",
[string]$TargetSystemCode = "",
[string]$CallbackUrl = "",
[string]$StyleVersionID = "",
[string]$RequestedBy = "demo-user",
[bool]$WaitForJob = $true,
[bool]$IncludeArtifacts = $true,
[int]$TimeoutSec = 180
)
. (Join-Path $PSScriptRoot "common.ps1")
Import-RsDemoEnvironment
if (-not $ResultID -and -not $ResultCode) {
throw "ResultID or ResultCode is required"
}
$result = if ($ResultID) {
Invoke-RsApi -Method "GET" -Path ("/api/v1/results/{0}" -f $ResultID)
} else {
Get-RsResultByCode -ProjectCode $ProjectCode -Code $ResultCode
}
if (-not $StyleVersionID) {
if ($result.delivery -and $result.delivery.style_version -and $result.delivery.style_version.id) {
$StyleVersionID = $result.delivery.style_version.id
} elseif ($result.styles -and $result.styles.Count -gt 0) {
$StyleVersionID = $result.styles[0].id
}
}
if (-not $StyleVersionID) {
throw "No style version is bound to the target result"
}
$body = @{
requested_by = $RequestedBy
style_version_id = $StyleVersionID
include_artifacts = $IncludeArtifacts
}
if ($TargetSystemCode) {
$body.target_system_code = $TargetSystemCode
}
if ($CallbackUrl) {
$body.callback_url = $CallbackUrl
}
if (-not $TargetSystemCode -and -not $CallbackUrl) {
$body.callback_url = "http://127.0.0.1:8920/callback"
}
$push = Invoke-RsApi -Method "POST" -Path ("/api/v1/results/{0}/pushes" -f $result.id) -Body $body
$job = $null
if ($WaitForJob) {
$job = Wait-RsJob -JobID $push.job.id -TimeoutSec $TimeoutSec
}
[ordered]@{
api_base_url = Get-RsApiBaseUrl
result_id = $result.id
result_code = $result.code
style_version_id = $StyleVersionID
target_system_code = $push.target_system_code
callback_url = $push.callback_url
publish_id = $push.publish_id
job_id = $push.job.id
job_status = if ($job) { $job.status } else { $push.job.status }
resource_kind = $push.resource_kind
} | ConvertTo-Json -Depth 8
+27
View File
@@ -0,0 +1,27 @@
param(
[string]$ProjectCode = "default",
[string]$Code = "demo_callback",
[string]$Name = "Demo Callback Receiver",
[string]$CallbackUrl = "http://127.0.0.1:8920/callback",
[string]$CallbackMethod = "POST",
[int]$TimeoutSeconds = 30,
[string]$CreatedBy = "demo-user"
)
. (Join-Path $PSScriptRoot "common.ps1")
Import-RsDemoEnvironment
$target = Ensure-RsTargetSystem `
-ProjectCode $ProjectCode `
-Code $Code `
-Name $Name `
-CallbackUrl $CallbackUrl `
-CallbackMethod $CallbackMethod `
-TimeoutSeconds $TimeoutSeconds `
-CreatedBy $CreatedBy `
-CallbackHeaders @{
"X-Demo-System" = "tile-server-demo"
}
$target | ConvertTo-Json -Depth 8
+16
View File
@@ -0,0 +1,16 @@
@echo off
setlocal
chcp 65001 >nul 2>&1
set "ROOT=%~dp0"
if "%PYTHON_EXE%"=="" set "PYTHON_EXE=C:\Users\Administrator\.conda\envs\InSAR\python.exe"
if "%DEMO_CALLBACK_PORT%"=="" set "DEMO_CALLBACK_PORT=8920"
if "%DEMO_CALLBACK_PATH%"=="" set "DEMO_CALLBACK_PATH=/callback"
if "%DEMO_CALLBACK_OUTPUT_DIR%"=="" set "DEMO_CALLBACK_OUTPUT_DIR=%ROOT%..\temp\demo-callback"
if not exist "%PYTHON_EXE%" (
echo Python interpreter not found: %PYTHON_EXE%
exit /b 1
)
"%PYTHON_EXE%" "%ROOT%demo_callback_receiver.py" --host 127.0.0.1 --port %DEMO_CALLBACK_PORT% --path %DEMO_CALLBACK_PATH% --output-dir "%DEMO_CALLBACK_OUTPUT_DIR%"