217 lines
7.5 KiB
Python
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,
|
|
)
|