Files
TJWaterServerBinary/app/services/project_provisioning.py
T
jiang 90b02057bc
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m13s
feat(projects): automate project infrastructure provisioning
2026-09-11 10:57:51 +08:00

217 lines
7.5 KiB
Python

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,
)