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
@@ -241,6 +241,67 @@ class MetadataRepository:
await self.session.refresh(project)
return project
async def create_provisioned_project(
self,
*,
name: str,
code: str,
description: str | None,
gs_workspace: str,
map_extent: dict,
creator_user_id: UUID,
business_dsn: str,
timescale_dsn: str,
pool_min_size: int = 1,
pool_max_size: int = 4,
) -> models.Project:
"""Atomically expose a fully provisioned project and both DB routes."""
business_secret = _encrypt_database_secret(business_dsn)
timescale_secret = _encrypt_database_secret(timescale_dsn)
project = models.Project(
id=uuid4(),
name=name,
code=code,
description=description,
gs_workspace=gs_workspace,
map_extent=map_extent,
status="active",
created_at=_utcnow(),
updated_at=_utcnow(),
)
records = (
project,
models.ProjectDatabase(
id=uuid4(),
project_id=project.id,
db_role="biz_data",
db_type="postgresql",
dsn_encrypted=business_secret,
pool_min_size=pool_min_size,
pool_max_size=pool_max_size,
),
models.ProjectDatabase(
id=uuid4(),
project_id=project.id,
db_role="iot_data",
db_type="timescaledb",
dsn_encrypted=timescale_secret,
pool_min_size=pool_min_size,
pool_max_size=pool_max_size,
),
models.UserProjectMembership(
id=uuid4(),
user_id=creator_user_id,
project_id=project.id,
project_role="member",
),
)
for record in records:
self.session.add(record)
await self.session.commit()
await self.session.refresh(project)
return project
async def update_project(
self,
project_id: UUID,
@@ -0,0 +1,30 @@
from __future__ import annotations
from app.native.wndb.core.connection import project_connection
def get_project_map_bbox(project: str) -> tuple[float, float, float, float]:
"""Return the combined published node/link bounds in EPSG:3857."""
with project_connection(project) as conn, conn.cursor() as cur:
cur.execute(
"""
with project_geometries as (
select geom from gis.junctions
union all
select geom from gis.pipes
), bounds as (
select ST_Extent(geom) as extent from project_geometries
)
select ST_XMin(extent) as minx,
ST_YMin(extent) as miny,
ST_XMax(extent) as maxx,
ST_YMax(extent) as maxy
from bounds
"""
)
row = cur.fetchone()
if row is None or any(
row[key] is None for key in ("minx", "miny", "maxx", "maxy")
):
raise RuntimeError("Imported model has no publishable GIS geometry")
return tuple(float(row[key]) for key in ("minx", "miny", "maxx", "maxy"))
+10 -4
View File
@@ -62,12 +62,18 @@ def get_project_database_name(name: str) -> str:
def get_project_template_database_name(name: str | None = None) -> str:
"""Return the configured immutable template for the WNDB schema version.
"""Return the network-data template paired with one physical BizDB.
The template belongs to the database schema version, not to an individual
logical project or its temporary physical database name.
Project routing is resolved before the suffix is applied, so a logical
project such as ``tjwater_v2`` uses ``tjwater_v2_template``.
"""
return settings.WNDB_TEMPLATE_DB_NAME
database_name = get_project_database_name(name or settings.DB_NAME)
return f"{database_name}_template"
def get_schema_template_database_name() -> str:
"""Return the immutable, data-free template used to create BizDB schemas."""
return settings.WNDB_SCHEMA_TEMPLATE_DB_NAME
def get_project_pgconn_string(db_name: str | None = None) -> str:
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
from contextlib import contextmanager
import re
from threading import RLock
from typing import Iterator
from psycopg import Connection, sql
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from app.core.config import get_timescaledb_pgconn_string, settings
from .sync_pool import close_timescale_pool
_DATABASE_NAME = re.compile(r"^[a-z][a-z0-9_]{0,49}$")
_SERVER_DATABASES = frozenset({"template0", "template1", "postgres"})
_admin_pool: ConnectionPool | None = None
_admin_conninfo: str | None = None
_lock = RLock()
def validate_timescale_database_name(name: str, *, allow_template: bool = False) -> str:
if not _DATABASE_NAME.fullmatch(name):
raise ValueError(
"TimescaleDB database name must start with a lowercase letter and "
"contain only lowercase letters, digits, and underscores"
)
protected = {*_SERVER_DATABASES, settings.TIMESCALEDB_SCHEMA_TEMPLATE_DB_NAME}
if name in protected and not (
allow_template and name == settings.TIMESCALEDB_SCHEMA_TEMPLATE_DB_NAME
):
raise ValueError(f"TimescaleDB database {name!r} is protected")
return name
def _get_admin_pool() -> ConnectionPool:
global _admin_pool, _admin_conninfo
conninfo = get_timescaledb_pgconn_string(db_name="postgres")
with _lock:
if (
_admin_pool is not None
and not _admin_pool.closed
and _admin_conninfo == conninfo
):
return _admin_pool
if _admin_pool is not None and not _admin_pool.closed:
_admin_pool.close()
_admin_pool = ConnectionPool(
conninfo=conninfo,
min_size=0,
max_size=2,
kwargs={"autocommit": True, "row_factory": dict_row},
check=ConnectionPool.check_connection,
open=True,
)
_admin_conninfo = conninfo
return _admin_pool
@contextmanager
def timescale_admin_connection() -> Iterator[Connection]:
with _get_admin_pool().connection() as conn:
yield conn
def timescale_database_exists(name: str) -> bool:
with timescale_admin_connection() as conn, conn.cursor() as cur:
cur.execute("select 1 from pg_database where datname = %s", (name,))
return cur.fetchone() is not None
def require_timescale_schema_template() -> str:
template = settings.TIMESCALEDB_SCHEMA_TEMPLATE_DB_NAME
validate_timescale_database_name(template, allow_template=True)
if not timescale_database_exists(template):
raise RuntimeError(
f"TimescaleDB schema template {template!r} does not exist"
)
return template
def create_timescale_database(name: str) -> None:
validate_timescale_database_name(name)
template = require_timescale_schema_template()
close_timescale_pool(name)
with timescale_admin_connection() as conn, conn.cursor() as cur:
cur.execute(
"select pg_advisory_lock(hashtextextended(%s, 0))",
(f"tjwater:timescaledb:{name}",),
)
try:
cur.execute("select 1 from pg_database where datname = %s", (name,))
if cur.fetchone() is not None:
raise ValueError(f"TimescaleDB database {name!r} already exists")
cur.execute(
"select datallowconn from pg_database where datname = %s",
(template,),
)
row = cur.fetchone()
if row is None:
raise RuntimeError(
f"TimescaleDB schema template {template!r} does not exist"
)
template_allowed = bool(row["datallowconn"])
if template_allowed:
cur.execute(
"update pg_database set datallowconn = false where datname = %s",
(template,),
)
try:
cur.execute(
"select pg_terminate_backend(pid) from pg_stat_activity "
"where datname = %s and pid <> pg_backend_pid()",
(template,),
)
cur.execute(
sql.SQL("create database {} with template = {}").format(
sql.Identifier(name),
sql.Identifier(template),
)
)
finally:
if template_allowed:
cur.execute(
"update pg_database set datallowconn = true where datname = %s",
(template,),
)
finally:
cur.execute(
"select pg_advisory_unlock(hashtextextended(%s, 0))",
(f"tjwater:timescaledb:{name}",),
)
def delete_timescale_database(name: str) -> None:
validate_timescale_database_name(name)
close_timescale_pool(name)
with timescale_admin_connection() as conn, conn.cursor() as cur:
cur.execute(
"select pg_advisory_lock(hashtextextended(%s, 0))",
(f"tjwater:timescaledb:{name}",),
)
try:
cur.execute("select 1 from pg_database where datname = %s", (name,))
if cur.fetchone() is None:
return
cur.execute(
"update pg_database set datallowconn = false where datname = %s",
(name,),
)
cur.execute(
"select pg_terminate_backend(pid) from pg_stat_activity "
"where datname = %s and pid <> pg_backend_pid()",
(name,),
)
cur.execute(
sql.SQL("drop database {}").format(sql.Identifier(name))
)
finally:
cur.execute(
"select pg_advisory_unlock(hashtextextended(%s, 0))",
(f"tjwater:timescaledb:{name}",),
)
+1
View File
@@ -0,0 +1 @@
"""GeoServer administration adapters."""
+224
View File
@@ -0,0 +1,224 @@
from __future__ import annotations
from dataclasses import dataclass
import math
import time
from xml.etree import ElementTree
import httpx
from app.core.config import settings
PROJECT_LAYER_NAMES = (
"junctions",
"pipes",
"pumps",
"reservoirs",
"scada_devices",
"tanks",
"valves",
)
class GeoServerProvisioningError(RuntimeError):
pass
@dataclass(frozen=True)
class GeoServerDatabaseConfig:
host: str
port: str
user: str
password: str
def _longitude(web_mercator_x: float) -> float:
return web_mercator_x * 180.0 / 20037508.342789244
def _latitude(web_mercator_y: float) -> float:
degrees = web_mercator_y * 180.0 / 20037508.342789244
return 180.0 / math.pi * (
2.0 * math.atan(math.exp(degrees * math.pi / 180.0)) - math.pi / 2.0
)
class GeoServerAdminClient:
def __init__(self, client: httpx.Client | None = None) -> None:
if not settings.GEOSERVER_USERNAME or not settings.GEOSERVER_PASSWORD:
raise GeoServerProvisioningError(
"GEOSERVER_USERNAME and GEOSERVER_PASSWORD must be configured"
)
self._owns_client = client is None
self._client = client or httpx.Client(
base_url=settings.GEOSERVER_URL.rstrip("/"),
auth=(settings.GEOSERVER_USERNAME, settings.GEOSERVER_PASSWORD),
timeout=30.0,
)
def __enter__(self) -> "GeoServerAdminClient":
return self
def __exit__(self, *_args) -> None:
if self._owns_client:
self._client.close()
@staticmethod
def database_config() -> GeoServerDatabaseConfig:
return GeoServerDatabaseConfig(
host=settings.GEOSERVER_DB_HOST or settings.DB_HOST,
port=settings.GEOSERVER_DB_PORT or settings.DB_PORT,
user=settings.GEOSERVER_DB_USER or settings.DB_USER,
password=settings.GEOSERVER_DB_PASSWORD or settings.DB_PASSWORD,
)
def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
response = self._client.request(method, path, **kwargs)
if response.is_error:
raise GeoServerProvisioningError(
f"GeoServer {method} {path} failed with HTTP {response.status_code}"
)
return response
def check_ready(self) -> None:
self._request("GET", "/rest/about/version.json")
def workspace_exists(self, workspace: str) -> bool:
response = self._client.get(f"/rest/workspaces/{workspace}.json")
if response.status_code == 404:
return False
if response.is_error:
raise GeoServerProvisioningError(
f"GeoServer workspace check failed with HTTP {response.status_code}"
)
return True
def create_project_workspace(
self,
*,
workspace: str,
database_name: str,
map_bbox: tuple[float, float, float, float],
) -> tuple[str, ...]:
if self.workspace_exists(workspace):
raise ValueError(f"GeoServer workspace {workspace!r} already exists")
self._request(
"POST",
"/rest/workspaces",
json={"workspace": {"name": workspace}},
)
database = self.database_config()
entries = {
"dbtype": "postgis",
"host": database.host,
"port": database.port,
"database": database_name,
"schema": "gis",
"user": database.user,
"passwd": database.password,
"namespace": workspace,
"Expose primary keys": "true",
"Estimated extends": "true",
"validate connections": "true",
"min connections": "1",
"max connections": "10",
"Connection timeout": "20",
}
self._request(
"POST",
f"/rest/workspaces/{workspace}/datastores",
json={
"dataStore": {
"name": workspace,
"description": f"{workspace} GIS materialized views",
"type": "PostGIS",
"enabled": True,
"connectionParameters": {
"entry": [
{"@key": key, "$": value}
for key, value in entries.items()
]
},
}
},
)
minx, miny, maxx, maxy = map_bbox
geographic_bbox = {
"minx": _longitude(minx),
"miny": _latitude(miny),
"maxx": _longitude(maxx),
"maxy": _latitude(maxy),
"crs": "EPSG:4326",
}
native_bbox = {
"minx": minx,
"miny": miny,
"maxx": maxx,
"maxy": maxy,
"crs": "EPSG:3857",
}
for layer in PROJECT_LAYER_NAMES:
self._request(
"POST",
f"/rest/workspaces/{workspace}/datastores/{workspace}/featuretypes",
json={
"featureType": {
"name": layer,
"nativeName": layer,
"title": layer,
"srs": "EPSG:3857",
"projectionPolicy": "FORCE_DECLARED",
"enabled": True,
"advertised": True,
"nativeBoundingBox": native_bbox,
"latLonBoundingBox": geographic_bbox,
}
},
)
self._set_client_cache(workspace, layer)
return PROJECT_LAYER_NAMES
def _set_client_cache(self, workspace: str, layer: str) -> None:
path = f"/gwc/rest/layers/{workspace}:{layer}.xml"
response: httpx.Response | None = None
for _ in range(20):
response = self._client.get(path)
if response.status_code == 200:
break
if response.status_code != 404:
raise GeoServerProvisioningError(
f"GeoWebCache layer check failed with HTTP {response.status_code}"
)
time.sleep(0.1)
if response is None or response.status_code != 200:
raise GeoServerProvisioningError(
f"GeoWebCache layer {workspace}:{layer} was not registered"
)
root = ElementTree.fromstring(response.content)
expiry = root.find("expireClients")
if expiry is None:
expiry = ElementTree.SubElement(root, "expireClients")
expiry.text = str(settings.GEOSERVER_CLIENT_CACHE_SECONDS)
self._request(
"PUT",
path,
content=ElementTree.tostring(
root,
encoding="utf-8",
xml_declaration=True,
),
headers={"Content-Type": "application/xml"},
)
def delete_workspace(self, workspace: str) -> None:
response = self._client.delete(
f"/rest/workspaces/{workspace}",
params={"recurse": "true"},
)
if response.status_code == 404:
return
if response.is_error:
raise GeoServerProvisioningError(
f"GeoServer workspace cleanup failed with HTTP {response.status_code}"
)