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