feat(projects): automate project infrastructure provisioning
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m13s

This commit is contained in:
2026-09-11 10:57:51 +08:00
parent 10a7a66a41
commit 90b02057bc
38 changed files with 2345 additions and 189 deletions
+15 -29
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import os
from datetime import datetime, timedelta
from typing import Any
from uuid import UUID
@@ -10,7 +9,7 @@ import pandas as pd
from app.algorithms.burst_localization import run_burst_location
from app.infra.db.postgresql.scada import get_all_scada_info
from app.infra.db.timescaledb.internal_queries import InternalQueries
from app.native.wndb.inp.exporter import dump_inp
from app.services.project_inp import temporary_project_inp
from app.services.scheme_management import (
get_analysis_run,
store_scheme_info,
@@ -300,20 +299,20 @@ def run_burst_location_by_network(
burst_flow_samples = 1 if burst_flow_series is not None else 0
normal_flow_samples = 1 if normal_flow_series is not None else 0
inp_path = _prepare_burst_inp(network)
result = run_burst_location(
wn_inp_path=inp_path,
pressure_scada_ids=selected_pressure_ids,
burst_pressure=burst_pressure_series,
normal_pressure=normal_pressure_series,
burst_leakage=burst_leakage,
flow_scada_ids=selected_flow_ids,
burst_flow=burst_flow_series,
normal_flow=normal_flow_series,
min_dpressure=min_dpressure,
basic_pressure=basic_pressure,
visualize_partition=False,
)
with temporary_project_inp(network, purpose="burst-location") as inp_path:
result = run_burst_location(
wn_inp_path=str(inp_path),
pressure_scada_ids=selected_pressure_ids,
burst_pressure=burst_pressure_series,
normal_pressure=normal_pressure_series,
burst_leakage=burst_leakage,
flow_scada_ids=selected_flow_ids,
burst_flow=burst_flow_series,
normal_flow=normal_flow_series,
min_dpressure=min_dpressure,
basic_pressure=basic_pressure,
visualize_partition=False,
)
payload: dict[str, Any] = {
**result,
@@ -775,16 +774,3 @@ def _normalize_timeseries_by_id(
def _to_datetime(value: datetime | str) -> datetime:
return parse_utc_time(value)
def _prepare_burst_inp(network: str) -> str:
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
db_inp_dir = os.path.join(project_root, "db_inp")
os.makedirs(db_inp_dir, exist_ok=True)
inp_path = os.path.join(db_inp_dir, f"{network}.burst.inp")
if os.path.isfile(inp_path) and os.path.getsize(inp_path) > 0:
return inp_path
dump_inp(network, inp_path, "2")
if not os.path.isfile(inp_path) or os.path.getsize(inp_path) <= 0:
raise ValueError(f"爆管定位 INP 文件无效: {inp_path}")
return inp_path
+20 -38
View File
@@ -14,7 +14,7 @@ from app.native.wndb.gis.network_views import (
get_network_link_nodes,
get_network_node_coords,
)
from app.native.wndb.inp.exporter import dump_inp
from app.services.project_inp import temporary_project_inp
from app.services.scheme_management import store_analysis_run_with_result
from app.domain.time import parse_utc_time, utc_now
@@ -42,8 +42,6 @@ def run_leakage_identification(
sensor_nodes: list[str] | None = None,
scheme_name: str | None = None,
) -> dict[str, Any]:
inp_path = _prepare_leakage_inp(network)
selected_sensor_nodes = (
list(dict.fromkeys([node for node in (sensor_nodes or []) if node]))
if sensor_nodes
@@ -52,7 +50,7 @@ def run_leakage_identification(
if not selected_sensor_nodes:
raise ValueError("未提供有效传感器节点,且系统未识别到可用压力传感器。")
area_map, areas, node_coords = _build_area_map_by_topology(
area_map, areas, _ = _build_area_map_by_topology(
network, selected_sensor_nodes, dma_count
)
@@ -73,26 +71,25 @@ def run_leakage_identification(
observed_df = observed_pressure_data
q_sum_m3s = DmaLeakageOptimizer._flow_to_m3s(q_sum, q_sum_unit)
identifier = DmaLeakageOptimizer(
inp_path=inp_path,
sensor_nodes=selected_sensor_nodes,
area_map=area_map,
start_time=start_time,
duration=duration,
timestep=timestep,
q_sum=q_sum_m3s,
)
result_df = identifier.run_identification(
observed_pressure_data=observed_df,
pop_size=pop_size,
max_gen=max_gen,
n_workers=n_workers,
output_flow_unit=output_flow_unit,
save_result=False,
)
with temporary_project_inp(network, purpose="dma-leakage") as inp_path:
identifier = DmaLeakageOptimizer(
inp_path=str(inp_path),
sensor_nodes=selected_sensor_nodes,
area_map=area_map,
start_time=start_time,
duration=duration,
timestep=timestep,
q_sum=q_sum_m3s,
)
result_df = identifier.run_identification(
observed_pressure_data=observed_df,
pop_size=pop_size,
max_gen=max_gen,
n_workers=n_workers,
output_flow_unit=output_flow_unit,
save_result=False,
)
rows = result_df.to_dict(orient="records")
# node_visual_payload = _build_node_visual_payload(area_map, node_coords, rows)
# drawing_payload = _build_drawing_payload(node_visual_payload)
payload = {
"result_path": result_df.attrs.get("result_path"),
"sensor_nodes": selected_sensor_nodes,
@@ -100,8 +97,6 @@ def run_leakage_identification(
"area_count": len(set(area_map.values())),
"node_area_map": area_map,
"areas": areas,
# "node_visual_payload": node_visual_payload,
# "drawing_payload": drawing_payload,
"rows": rows,
}
if scheme_name:
@@ -326,16 +321,3 @@ def _build_observed_pressure_from_scada(
def _to_datetime(value: datetime | str) -> datetime:
return parse_utc_time(value)
def _prepare_leakage_inp(network: str) -> str:
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
db_inp_dir = os.path.join(project_root, "db_inp")
os.makedirs(db_inp_dir, exist_ok=True)
inp_path = os.path.join(db_inp_dir, f"{network}.leakage.inp")
if os.path.isfile(inp_path) and os.path.getsize(inp_path) > 0:
return inp_path
dump_inp(network, inp_path, "2")
if not os.path.isfile(inp_path) or os.path.getsize(inp_path) <= 0:
raise ValueError(f"漏损识别 INP 文件无效: {inp_path}")
return inp_path
+35
View File
@@ -0,0 +1,35 @@
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from tempfile import NamedTemporaryFile
from app.native.wndb.inp.exporter import dump_inp
PROJECT_INP_DIRECTORY = Path("db_inp")
@contextmanager
def temporary_project_inp(
project_code: str,
*,
purpose: str,
version: str = "2",
) -> Iterator[Path]:
"""Export one project model to an isolated INP and remove it afterwards."""
PROJECT_INP_DIRECTORY.mkdir(parents=True, exist_ok=True)
with NamedTemporaryFile(
dir=PROJECT_INP_DIRECTORY,
prefix=f"{purpose}_",
suffix=".inp",
delete=False,
) as temporary_file:
path = Path(temporary_file.name).resolve()
try:
dump_inp(project_code, str(path), version)
if not path.is_file() or path.stat().st_size == 0:
raise ValueError(f"项目 {project_code!r} 的 INP 导出失败")
yield path
finally:
path.unlink(missing_ok=True)
+216
View File
@@ -0,0 +1,216 @@
from __future__ import annotations
from dataclasses import dataclass
import logging
from pathlib import Path
import re
from urllib.parse import quote
from app.core.config import settings
from app.infra.db.timescaledb.lifecycle import (
create_timescale_database,
delete_timescale_database,
require_timescale_schema_template,
timescale_database_exists,
)
from app.infra.db.postgresql.project_spatial import get_project_map_bbox
from app.infra.geoserver.client import GeoServerAdminClient
from app.native.wndb.core.project_templates import (
create_project_model_template,
delete_project_model_template,
ensure_replication_worker_capacity,
)
from app.native.wndb.core.projects import create_project, delete_project, have_project
from app.services.network_import import network_update
logger = logging.getLogger(__name__)
_PROJECT_CODE = re.compile(r"^[a-z][a-z0-9_]{0,49}$")
class ProjectProvisioningError(RuntimeError):
def __init__(self, stage: str, cause: Exception, cleanup_errors: list[str]) -> None:
self.stage = stage
self.cause = cause
self.cleanup_errors = cleanup_errors
cleanup_suffix = (
f"; cleanup failures: {', '.join(cleanup_errors)}"
if cleanup_errors
else ""
)
super().__init__(f"Project provisioning failed at {stage}: {cause}{cleanup_suffix}")
def validate_project_code(code: str) -> str:
if not _PROJECT_CODE.fullmatch(code):
raise ValueError(
"Project code must start with a lowercase letter and contain only "
"lowercase letters, digits, and underscores"
)
if code.endswith("_template"):
raise ValueError("Project code must not end with '_template'")
if code in {
"postgres",
"template0",
"template1",
settings.METADATA_DB_NAME.casefold(),
}:
raise ValueError(f"Project code {code!r} is reserved")
return code
def _database_url(*, timescale: bool, database_name: str) -> str:
if timescale:
host = settings.TIMESCALEDB_DB_HOST
port = settings.TIMESCALEDB_DB_PORT
user = settings.TIMESCALEDB_DB_USER
password = settings.TIMESCALEDB_DB_PASSWORD
else:
host = settings.DB_HOST
port = settings.DB_PORT
user = settings.DB_USER
password = settings.DB_PASSWORD
return (
f"postgresql://{quote(user, safe='')}:{quote(password, safe='')}"
f"@{host}:{port}/{database_name}"
)
@dataclass
class ProvisionedProjectInfrastructure:
code: str
workspace: str
model_template: str
map_bbox: tuple[float, float, float, float]
layers: tuple[str, ...]
@property
def business_dsn(self) -> str:
return _database_url(timescale=False, database_name=self.code)
@property
def timescale_dsn(self) -> str:
return _database_url(timescale=True, database_name=self.code)
def cleanup(self) -> list[str]:
errors: list[str] = []
cleanup_steps = (
("geoserver", self._delete_geoserver),
("timescaledb", lambda: delete_timescale_database(self.code)),
("model_template", lambda: delete_project_model_template(self.code)),
("business_database", lambda: delete_project(self.code)),
)
for name, cleanup in cleanup_steps:
try:
cleanup()
except Exception as exc: # preserve every cleanup attempt
logger.exception("Project provisioning cleanup failed at %s", name)
errors.append(name)
return errors
def _delete_geoserver(self) -> None:
with GeoServerAdminClient() as geoserver:
geoserver.delete_workspace(self.workspace)
def _preflight(code: str, workspace: str, geoserver: GeoServerAdminClient) -> None:
validate_project_code(code)
if not _PROJECT_CODE.fullmatch(workspace):
raise ValueError(
"GeoServer workspace must start with a lowercase letter and contain "
"only lowercase letters, digits, and underscores"
)
if have_project(settings.WNDB_SCHEMA_TEMPLATE_DB_NAME) is False:
raise RuntimeError(
f"Business schema template {settings.WNDB_SCHEMA_TEMPLATE_DB_NAME!r} does not exist"
)
require_timescale_schema_template()
ensure_replication_worker_capacity()
if have_project(code):
raise ValueError(f"Business database {code!r} already exists")
model_template = f"{code}_template"
if have_project(model_template):
raise ValueError(f"Project model template {model_template!r} already exists")
if timescale_database_exists(code):
raise ValueError(f"TimescaleDB database {code!r} already exists")
geoserver.check_ready()
if geoserver.workspace_exists(workspace):
raise ValueError(f"GeoServer workspace {workspace!r} already exists")
def provision_project_infrastructure(
*,
code: str,
workspace: str,
inp_path: str | Path,
) -> ProvisionedProjectInfrastructure:
stage = "preflight"
business_created = False
template_attempted = False
timescale_created = False
workspace_attempted = False
map_bbox: tuple[float, float, float, float] | None = None
layers: tuple[str, ...] = ()
try:
with GeoServerAdminClient() as geoserver:
_preflight(code, workspace, geoserver)
stage = "business_database"
create_project(code)
business_created = True
stage = "model_import"
network_update(str(inp_path), code)
map_bbox = get_project_map_bbox(code)
stage = "model_template"
template_attempted = True
model_template = create_project_model_template(code)
stage = "timescaledb"
create_timescale_database(code)
timescale_created = True
stage = "geoserver"
workspace_attempted = True
layers = geoserver.create_project_workspace(
workspace=workspace,
database_name=code,
map_bbox=map_bbox,
)
except Exception as exc:
cleanup_errors: list[str] = []
if workspace_attempted:
try:
with GeoServerAdminClient() as geoserver:
geoserver.delete_workspace(workspace)
except Exception:
logger.exception("Failed to remove GeoServer workspace %s", workspace)
cleanup_errors.append("geoserver")
if timescale_created:
try:
delete_timescale_database(code)
except Exception:
logger.exception("Failed to remove TimescaleDB database %s", code)
cleanup_errors.append("timescaledb")
if template_attempted:
try:
delete_project_model_template(code)
except Exception:
logger.exception("Failed to remove project template for %s", code)
cleanup_errors.append("model_template")
if business_created:
try:
delete_project(code)
except Exception:
logger.exception("Failed to remove business database %s", code)
cleanup_errors.append("business_database")
raise ProjectProvisioningError(stage, exc, cleanup_errors) from exc
assert map_bbox is not None
return ProvisionedProjectInfrastructure(
code=code,
workspace=workspace,
model_template=f"{code}_template",
map_bbox=map_bbox,
layers=layers,
)
+22 -15
View File
@@ -1,3 +1,4 @@
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import datetime
import fcntl
@@ -16,7 +17,7 @@ import wntr
from app.algorithms.pressure_sensor_placement import kmeans_placement
from app.algorithms.pressure_sensor_placement import sensitivity_placement
from app.infra.db.postgresql import sensor_placement as sensor_placement_repository
from app.native.wndb.inp.exporter import dump_inp
from app.services.project_inp import temporary_project_inp
class SensorPlacementNotFoundError(LookupError):
@@ -31,7 +32,7 @@ class SensorPlacementConflictError(RuntimeError):
pass
def _sensor_inp_path(project_code: str) -> Path:
def _sensor_lock_path(project_code: str) -> Path:
if (
not project_code
or project_code in {".", ".."}
@@ -40,14 +41,13 @@ def _sensor_inp_path(project_code: str) -> Path:
or "\x00" in project_code
):
raise SensorPlacementValidationError("管网名称不是有效的项目标识")
return Path("db_inp") / f"{project_code}.db.inp"
return Path("db_inp") / f"{project_code}.sensor.lock"
@contextmanager
def _sensor_inp_lock(project_code: str):
inp_path = _sensor_inp_path(project_code)
inp_path.parent.mkdir(parents=True, exist_ok=True)
lock_path = inp_path.with_suffix(".sensor.lock")
def _sensor_run_lock(project_code: str) -> Iterator[None]:
lock_path = _sensor_lock_path(project_code)
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("w", encoding="utf-8") as lock_file:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -56,11 +56,23 @@ def _sensor_inp_lock(project_code: str):
"当前项目已有监测点优化任务正在运行,请稍后重试"
) from exc
try:
yield inp_path
yield
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
@contextmanager
def _sensor_network_model(
project_code: str,
) -> Iterator[wntr.network.WaterNetworkModel]:
with _sensor_run_lock(project_code):
with temporary_project_inp(
project_code,
purpose="sensor-placement",
) as inp_path:
yield wntr.network.WaterNetworkModel(str(inp_path))
def _create_validated_placement(
project_code: str,
*,
@@ -88,10 +100,7 @@ def optimize_sensor_placement_by_sensitivity(
) -> dict[str, Any]:
"""Run sensitivity placement and persist the validated result."""
with _sensor_inp_lock(project_code):
inp_path = _sensor_inp_path(project_code)
dump_inp(project_code, str(inp_path), "2")
network_model = wntr.network.WaterNetworkModel(str(inp_path))
with _sensor_network_model(project_code) as network_model:
sensor_locations = sensitivity_placement.optimize_sensor_placement(
network_model,
sensor_num=sensor_count,
@@ -115,9 +124,7 @@ def optimize_sensor_placement_by_kmeans(
) -> dict[str, Any]:
"""Export the model, run K-means placement, and persist the result."""
with _sensor_inp_lock(project_code) as inp_path:
dump_inp(project_code, str(inp_path), "2")
network_model = wntr.network.WaterNetworkModel(str(inp_path))
with _sensor_network_model(project_code) as network_model:
sensor_locations = kmeans_placement.optimize_sensor_placement(
network_model,
sensor_count=sensor_count,